Merge remote-tracking branch 'origin/master' into feat/play-count-with-cache
Project CI / Backend tests (pull_request) Failing after 36s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m41s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Failing after 36s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m41s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -49,6 +49,7 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminPaymentOrderListResponse,
|
||||
AdminProjectSnapshotChannelsResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
@@ -801,6 +802,12 @@ export function listAdminRechargeOrders(
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminPaymentOrders(token: string) {
|
||||
return request<AdminPaymentOrderListResponse>('/admin/api/payment/orders', {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
|
||||
return request<AdminUserDetailResponse>(
|
||||
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
|
||||
|
||||
@@ -1054,6 +1054,39 @@ export interface AdminWechatPaymentCheckPayload {
|
||||
knownRefundsRefreshed: number;
|
||||
}
|
||||
|
||||
export interface AdminPaymentOrderEntry {
|
||||
orderId: string;
|
||||
appId: string;
|
||||
ownerUserId: string;
|
||||
merchantOrderId: string;
|
||||
title: string;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
provider: string;
|
||||
providerTradeNo: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdminPaymentOrderListResponse {
|
||||
entries: AdminPaymentOrderEntry[];
|
||||
webhooks: AdminPaymentWebhookEntry[];
|
||||
}
|
||||
|
||||
export interface AdminPaymentWebhookEntry {
|
||||
deliveryId: string;
|
||||
orderId: string;
|
||||
appId: string;
|
||||
callbackUrl: string;
|
||||
status: string;
|
||||
attemptCount: number;
|
||||
availableAt: string;
|
||||
lastErrorMessage: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundPreviewResponse {
|
||||
order: AdminRechargeOrderEntryPayload;
|
||||
paymentCheck: AdminWechatPaymentCheckPayload;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||
import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage';
|
||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||
@@ -297,6 +298,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'payment-orders' ? (
|
||||
<AdminPaymentOrderPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-generation-pricing' ? (
|
||||
<AdminEditorGenerationPricingPage
|
||||
token={token}
|
||||
|
||||
@@ -51,6 +51,7 @@ const routeIcons = {
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'recharge-orders': ReceiptText,
|
||||
'payment-orders': ReceiptText,
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AdminRouteId =
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'recharge-orders'
|
||||
| 'payment-orders'
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
|
||||
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
|
||||
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
|
||||
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
|
||||
{
|
||||
id: 'editor-generation-pricing',
|
||||
label: '模型定价',
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
AdminEmptyState,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminStatusPill,
|
||||
} from '@genarrative/shared/components';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
listAdminPaymentOrders,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminPaymentOrderEntry,
|
||||
AdminPaymentWebhookEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
type AdminPaymentOrderPageProps = {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
};
|
||||
|
||||
function statusTone(status: string): 'ok' | 'pending' | 'error' {
|
||||
if (status === 'paid') return 'ok';
|
||||
if (status === 'closed' || status === 'expired') return 'error';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function AdminPaymentOrderPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminPaymentOrderPageProps) {
|
||||
const [entries, setEntries] = useState<AdminPaymentOrderEntry[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<AdminPaymentWebhookEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
void listAdminPaymentOrders(token)
|
||||
.then((response) => {
|
||||
setEntries(response.entries ?? []);
|
||||
setWebhooks(response.webhooks ?? []);
|
||||
})
|
||||
.catch((nextError: unknown) => {
|
||||
handlePageError(nextError, onUnauthorized, (message) =>
|
||||
setError(message || formatAdminApiError(nextError)),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [onUnauthorized, token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
title="支付订单"
|
||||
description="平台统一收款的外部产品订单与到账状态。"
|
||||
actions={
|
||||
<button
|
||||
className="admin-button admin-button--secondary"
|
||||
type="button"
|
||||
onClick={load}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<AdminListPanel
|
||||
title="外部回调投递"
|
||||
count={`${webhooks.length} 条`}
|
||||
busy={isLoading}
|
||||
emptyText={<AdminEmptyState>暂无外部回调记录。</AdminEmptyState>}
|
||||
columns={[
|
||||
{
|
||||
key: 'delivery',
|
||||
label: '投递',
|
||||
render: (entry) => (
|
||||
<>
|
||||
<strong>{entry.deliveryId}</strong>
|
||||
<small className="admin-muted-block">{entry.orderId}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'app', label: '应用', render: (entry) => entry.appId },
|
||||
{
|
||||
key: 'callback',
|
||||
label: '回调地址',
|
||||
render: (entry) => entry.callbackUrl,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
render: (entry) => (
|
||||
<AdminStatusPill
|
||||
tone={
|
||||
entry.status === 'delivered'
|
||||
? 'ok'
|
||||
: entry.status === 'failed'
|
||||
? 'error'
|
||||
: 'pending'
|
||||
}
|
||||
>
|
||||
{entry.status}
|
||||
</AdminStatusPill>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attempts',
|
||||
label: '尝试次数',
|
||||
render: (entry) => entry.attemptCount,
|
||||
},
|
||||
{
|
||||
key: 'error',
|
||||
label: '最近错误',
|
||||
render: (entry) => entry.lastErrorMessage ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'updated',
|
||||
label: '更新时间',
|
||||
render: (entry) => entry.updatedAt,
|
||||
},
|
||||
]}
|
||||
rows={webhooks}
|
||||
rowKey={(entry) => entry.deliveryId}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="admin-alert admin-alert--error">{error}</div>
|
||||
) : null}
|
||||
<AdminListPanel
|
||||
title="订单记录"
|
||||
count={`${entries.length} 条`}
|
||||
busy={isLoading}
|
||||
emptyText={
|
||||
<AdminEmptyState>
|
||||
暂无支付订单,外部产品创建订单后会显示在这里。
|
||||
</AdminEmptyState>
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
key: 'order',
|
||||
label: '订单',
|
||||
render: (entry) => (
|
||||
<>
|
||||
<strong>{entry.merchantOrderId}</strong>
|
||||
<small className="admin-muted-block">{entry.orderId}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
label: '应用',
|
||||
render: (entry) => (
|
||||
<>
|
||||
<strong>{entry.title}</strong>
|
||||
<small className="admin-muted-block">{entry.appId}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
render: (entry) =>
|
||||
`${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`,
|
||||
},
|
||||
{ key: 'provider', label: '渠道', render: (entry) => entry.provider },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
render: (entry) => (
|
||||
<AdminStatusPill tone={statusTone(entry.status)}>
|
||||
{entry.status}
|
||||
</AdminStatusPill>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: '创建时间',
|
||||
render: (entry) => entry.createdAt,
|
||||
},
|
||||
{
|
||||
key: 'paid',
|
||||
label: '支付时间',
|
||||
render: (entry) => entry.paidAt ?? '-',
|
||||
},
|
||||
]}
|
||||
rows={entries}
|
||||
rowKey={(entry) => entry.orderId}
|
||||
/>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
|
||||
"check:tests:types": "tsc -p tsconfig.tests.json --noEmit",
|
||||
"typecheck": "npm run check:tests:types && tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.285",
|
||||
|
||||
@@ -86,7 +86,7 @@ maud = "0.27.0"
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
@@ -17,8 +17,14 @@
|
||||
"browser.status": "整体 passed={}",
|
||||
"browser.notStarted": "本次未启动 Chromium",
|
||||
"browser.evidence": "[AGC 浏览器事实证据]\nattempt={attempt}; completionError={completion_status}; browser={browser_status}; codeFingerprintChanged={output_changed}\n{}\n诊断与平台素材运行时观察:{}\n交互探针:{}\n客户端已保存结构化证据;它是事实输入,不代表 Codex 已阅读截图或已经完成修复。当前 cwd 是项目根目录;按需读取实际文件,并自行决定是否修改、再次试玩或直接回复。AGC 只负责启动浏览器、采集证据和执行项目边界,Codex 负责解释结果。",
|
||||
"browser.noFailureDetails": "无额外硬失败详情",
|
||||
"browser.noVisibleControls": "未找到可执行的可见控件",
|
||||
"browser.noFailureDetails": "无额外视觉检查失败详情",
|
||||
"browser.noVisibleControls": "无建议性交互探针记录",
|
||||
"browser.gameplayNotRun": "玩法检查:未执行(visual 仅提供视觉证据)。",
|
||||
"browser.gameplayMissing": "固定场景玩法结果缺失,不能确认通过。",
|
||||
"browser.gameplayNoDiagnostic": "无诊断记录",
|
||||
"browser.gameplayNoUnmetAssertion": "无",
|
||||
"browser.gameplayViewport": "{viewport}: gameplayPassed={passed};首条玩法诊断:{diagnostic};首个未通过断言:{assertion}",
|
||||
"browser.gameplayEvidence": "[固定场景玩法证据]\n{}\n断言 passed=false 表示未通过,也可能因前序阻断而未执行;修复以具体诊断为准,不把所有未通过项视为独立故障。固定场景通过不代表完整关卡已验证;reportPath 仅为宿主证据引用,无需读取私有报告。",
|
||||
"system.role": "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,负责当前任务的执行。先理解用户意图:普通对话直接回答,项目请求按需要检查、修改、运行和验证,并用简洁中文报告真实结果。",
|
||||
"system.workspaceBoundary": "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。",
|
||||
"system.toolAuthorization": "AGC 工具授权:agc_tools 使用当前登录会话。工具返回 401/403 时,报告登录或权限状态异常并停止,交由用户处理登录和权限。",
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ Canvas/WebGL probes can show whether registered image sources entered a core ren
|
||||
|
||||
`mode=visual` captures desktop/mobile screenshots, loading, layout and diagnostics without clicking gameplay controls. It does not prove that the game loop works. `mode=gameplay` uses the fixed approved scenario (`generic-v1` by default, or the existing `tetris-v1` / `lane-defense-v1` contract), not an agent-written autopilot. It exercises the scenario independently in desktop and mobile viewports and requires both results. The receipt declares `gameplayViewports` separately from `visualViewports`. Older reports with only a desktop `playtest` result do not prove mobile gameplay. These bounded scenarios do not establish completion of an arbitrary long level.
|
||||
|
||||
Use each viewport's `gameplayResults` to diagnose failures: start with its first `diagnostics` entry, then inspect `assertions` and the recorded initial/final phase, sequence and level. An assertion with `passed=false` may not have run because an earlier step blocked; do not treat every unmet assertion as an independent defect. Missing viewport evidence is not a pass. In visual mode, `gameplayResults` is empty because gameplay was not run. `reportPath` is a private host evidence reference; use the returned diagnostics instead of trying to read that report.
|
||||
|
||||
For `generic-v1`, expose exactly one `script#playable-web-game-state[type="application/json"]` whose JSON is a projection of actual game state:
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.35",
|
||||
"version": "2026-08-26.37",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-unity-editor",
|
||||
@@ -140,7 +140,7 @@
|
||||
"references/browser-evidence-contract.md",
|
||||
"references/runner-physics.mjs"
|
||||
],
|
||||
"sha256": "e0e7bdca529e068ce2974a5e312a267108ab5acb20e8f9d7c0c22d049d90b796"
|
||||
"sha256": "6800059c4e7bae70b0b42ec47175fc85e38d789e1759dca5df2067f715dc0657"
|
||||
},
|
||||
{
|
||||
"name": "agc-client-projection",
|
||||
|
||||
@@ -30,6 +30,8 @@ mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
mod direct_validation;
|
||||
#[cfg(test)]
|
||||
pub(crate) use direct_validation::browser_tool_result;
|
||||
mod generation;
|
||||
pub(crate) mod json_sidecar;
|
||||
pub(crate) mod redaction;
|
||||
@@ -43,9 +45,9 @@ pub mod tool;
|
||||
pub(crate) use art_manifest::*;
|
||||
use claude_code_cli::*;
|
||||
pub(crate) use claude_code_cli::{
|
||||
cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at,
|
||||
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
|
||||
game_creator_claude_code_cli_version_identity,
|
||||
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
|
||||
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
|
||||
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
|
||||
pub(crate) use codex_app_server::turn_error::*;
|
||||
|
||||
@@ -94,11 +94,14 @@ impl Drop for ActiveClaudeCodeTurnGuard {
|
||||
}
|
||||
}
|
||||
|
||||
struct ExternalMcpStopGuard;
|
||||
struct ExternalMcpStopGuard {
|
||||
root: PathBuf,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Drop for ExternalMcpStopGuard {
|
||||
fn drop(&mut self) {
|
||||
stop_external_mcp_loopback();
|
||||
stop_external_mcp_loopback_for_root(&self.root, &self.token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,6 +750,46 @@ fn parse_usage(value: &serde_json::Value) -> Option<platform_llm::LlmTokenUsage>
|
||||
})
|
||||
}
|
||||
|
||||
/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。
|
||||
///
|
||||
/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别
|
||||
/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` /
|
||||
/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。
|
||||
pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError {
|
||||
if let Some(status_code) = claude_code_failure_status_code(&detail) {
|
||||
return platform_llm::LlmError::Upstream {
|
||||
status_code,
|
||||
message: detail,
|
||||
};
|
||||
}
|
||||
if detail.contains("Claude Agent SDK sidecar 回合超时") {
|
||||
return platform_llm::LlmError::Timeout { attempts: 1 };
|
||||
}
|
||||
if detail.contains("Claude Code 缺少最终回复") {
|
||||
return platform_llm::LlmError::EmptyResponse;
|
||||
}
|
||||
if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON")
|
||||
{
|
||||
return platform_llm::LlmError::Deserialize(detail);
|
||||
}
|
||||
platform_llm::LlmError::Transport(detail)
|
||||
}
|
||||
|
||||
fn claude_code_failure_status_code(detail: &str) -> Option<u16> {
|
||||
["Request rejected (", "HTTP "].iter().find_map(|marker| {
|
||||
let tail = detail.split_once(marker)?.1;
|
||||
let digits = tail
|
||||
.chars()
|
||||
.take_while(|character| character.is_ascii_digit())
|
||||
.collect::<String>();
|
||||
if digits.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let status_code = digits.parse::<u16>().ok()?;
|
||||
(100..=599).contains(&status_code).then_some(status_code)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_claude_code_result(
|
||||
stdout: &[u8],
|
||||
request: &LlmRunRequest,
|
||||
@@ -758,7 +801,7 @@ fn parse_claude_code_result(
|
||||
let detail = claude_result_error_detail(&value)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
return Err(claude_code_failure_to_llm_error(format!(
|
||||
"Claude Code 返回失败终态{detail}"
|
||||
)));
|
||||
}
|
||||
@@ -920,9 +963,13 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>,
|
||||
) -> Result<String, String> {
|
||||
direct_turn_trace("claude-executor-enter");
|
||||
let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?;
|
||||
let (mcp_url, mcp_token) =
|
||||
start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?;
|
||||
direct_turn_trace("claude-mcp-loopback-ready");
|
||||
let _mcp_guard = ExternalMcpStopGuard;
|
||||
let _mcp_guard = ExternalMcpStopGuard {
|
||||
root: root.to_path_buf(),
|
||||
token: mcp_token.clone(),
|
||||
};
|
||||
let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?;
|
||||
std::fs::create_dir_all(&home)
|
||||
.map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?;
|
||||
@@ -1156,6 +1203,29 @@ mod tests {
|
||||
assert!(error.to_string().contains("Authentication failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_failure_reuses_codex_error_categories() {
|
||||
assert!(matches!(
|
||||
claude_code_failure_to_llm_error(
|
||||
"Claude Code 返回失败终态:API Error: Request rejected (429)".into()
|
||||
),
|
||||
platform_llm::LlmError::Upstream {
|
||||
status_code: 429,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
claude_code_failure_to_llm_error(
|
||||
"Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into()
|
||||
),
|
||||
platform_llm::LlmError::Timeout { attempts: 1 }
|
||||
));
|
||||
assert!(matches!(
|
||||
claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()),
|
||||
platform_llm::LlmError::EmptyResponse
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_direct_stream_result_and_final_text() {
|
||||
let mut observed = Vec::new();
|
||||
|
||||
@@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
.map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail)));
|
||||
.map_err(|detail| {
|
||||
TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail))
|
||||
});
|
||||
}
|
||||
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
|
||||
TurnError::EnvironmentNotReady(EnvironmentNotReady {
|
||||
|
||||
@@ -304,7 +304,7 @@ impl ModelCallKind {
|
||||
native,
|
||||
} => match native {
|
||||
Some(native) => native.is_retryable(),
|
||||
None => *status_code >= 500,
|
||||
None => matches!(*status_code, 401 | 408 | 429 | 500..=599),
|
||||
},
|
||||
Self::PaidCreditsInsufficient => false,
|
||||
Self::EmptyResponse => false,
|
||||
@@ -322,9 +322,16 @@ impl ModelCallKind {
|
||||
}
|
||||
Self::EmptyResponse => Some("模型未返回内容"),
|
||||
Self::PayloadInvalid { .. } => Some("模型回执无法解析"),
|
||||
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
|
||||
Self::RequestRejected { native } => {
|
||||
native.as_ref().and_then(NativeKind::public_summary)
|
||||
}
|
||||
Self::UpstreamFailed {
|
||||
status_code,
|
||||
native,
|
||||
} => native
|
||||
.as_ref()
|
||||
.and_then(NativeKind::public_summary)
|
||||
.or_else(|| upstream_status_summary(*status_code)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,13 +348,40 @@ impl ModelCallKind {
|
||||
Self::EmptyResponse | Self::PayloadInvalid { .. } => {
|
||||
Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断")
|
||||
}
|
||||
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
|
||||
native.as_ref().and_then(NativeKind::recovery_hint)
|
||||
}
|
||||
Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint),
|
||||
Self::UpstreamFailed {
|
||||
status_code,
|
||||
native,
|
||||
} => native
|
||||
.as_ref()
|
||||
.and_then(NativeKind::recovery_hint)
|
||||
.or_else(|| upstream_status_recovery_hint(*status_code)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_status_summary(status_code: u16) -> Option<&'static str> {
|
||||
Some(match status_code {
|
||||
401 => "上游服务拒绝认证(HTTP 401)",
|
||||
403 => "上游服务拒绝访问(HTTP 403)",
|
||||
408 => "上游请求超时(HTTP 408)",
|
||||
429 => "上游服务返回 HTTP 429",
|
||||
500..=599 => "上游服务暂时不可用",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> {
|
||||
Some(match status_code {
|
||||
401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断",
|
||||
403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断",
|
||||
408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断",
|
||||
429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断",
|
||||
500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════════════════
|
||||
// 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列
|
||||
// ══════════════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1327,6 +1361,31 @@ mod tests {
|
||||
assert!(!projected.is_model_repairable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
|
||||
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
|
||||
status_code: 429,
|
||||
message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(),
|
||||
});
|
||||
assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429"));
|
||||
assert_eq!(
|
||||
rate_limited.recovery_hint(),
|
||||
Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断")
|
||||
);
|
||||
assert!(rate_limited.is_retryable());
|
||||
assert!(!rate_limited.is_model_repairable());
|
||||
|
||||
let unauthorized = TurnError::from_model_call(&LlmError::Upstream {
|
||||
status_code: 401,
|
||||
message: "Claude Code 返回失败终态:HTTP 401".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
unauthorized.public_summary(),
|
||||
Some("上游服务拒绝认证(HTTP 401)")
|
||||
);
|
||||
assert!(unauthorized.is_retryable());
|
||||
}
|
||||
|
||||
/// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。
|
||||
#[test]
|
||||
fn terminal_native_kinds_are_not_fed_back_to_the_model() {
|
||||
|
||||
@@ -28,7 +28,22 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str =
|
||||
"game-creator-direct-active-turns-changed";
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
#[cfg(test)]
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
thread_local! {
|
||||
/// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主
|
||||
/// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随
|
||||
/// `TurnReservation::drop` 起整轮),并会在任意时刻广播「运行中的项目」变了。断言要观测的
|
||||
/// 是本测试自己触发的通知,不该被别的后台广播串台。
|
||||
///
|
||||
/// 这里必须留在测试线程作用域内:退役 `runtime_driver` 时这条口径曾被降级回进程级
|
||||
/// `AtomicU64`,于是 `agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change`
|
||||
/// 又回到偶发(同一片内前一个用例留下的后台回合广播进采样窗口)。
|
||||
///
|
||||
/// 代价:计数改成线程作用域后,**别的线程**上的重复 / 丢失通知不再被这条用例覆盖(那是
|
||||
/// 宿主后台回合的行为,本身就不该由单线程断言口径表达);要覆盖跨线程序,应另加用例
|
||||
/// 观察回合身份/序列号,而不是把计数器退回进程级。
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell<u64> =
|
||||
const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
@@ -54,7 +69,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
|
||||
pub(crate) fn emit_direct_active_turns_changed() {
|
||||
let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
#[cfg(test)]
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel);
|
||||
let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1));
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
@@ -66,7 +81,7 @@ pub(crate) fn emit_direct_active_turns_changed() {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn direct_active_turns_event_test_count() -> u64 {
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire)
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
|
||||
|
||||
@@ -4236,8 +4236,9 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
pub(crate) async fn start_external_client_tool_bridge(
|
||||
root: &Path,
|
||||
controlled_web_search: bool,
|
||||
direct_turn_execution: bool,
|
||||
) -> Result<DirectToolBridge, String> {
|
||||
start_tool_bridge_for_source(root, controlled_web_search, false).await
|
||||
start_tool_bridge_for_source(root, controlled_web_search, direct_turn_execution).await
|
||||
}
|
||||
|
||||
async fn start_tool_bridge_for_source(
|
||||
@@ -4421,6 +4422,115 @@ pub(super) fn drain_analytics_test_writer(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn browser_gameplay_results_reach_model_with_matching_viewports_and_screenshots() {
|
||||
for passed in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let evidence = if passed {
|
||||
super::super::direct_validation::tests::browser_evidence_fixture(temp.path())
|
||||
} else {
|
||||
super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path())
|
||||
};
|
||||
for viewport in &evidence.viewport_results {
|
||||
image::RgbaImage::new(2, 2)
|
||||
.save(&viewport.screenshot_path)
|
||||
.unwrap();
|
||||
}
|
||||
let receipt = super::super::direct_validation::browser_tool_result(
|
||||
temp.path(),
|
||||
&evidence,
|
||||
Some(crate::browser::BrowserPlaytestScenario::GenericV1),
|
||||
1,
|
||||
);
|
||||
assert!(evidence.viewport_results.iter().all(|result| result.passed));
|
||||
assert_eq!(receipt["passed"], passed);
|
||||
let outcome = bridge_validation_result(
|
||||
temp.path(),
|
||||
Ok(receipt.clone()),
|
||||
|report, screenshots| BrowserPlaytestError::PlaytestNotPassed {
|
||||
report,
|
||||
screenshots,
|
||||
},
|
||||
)
|
||||
.map_err(|failure| {
|
||||
let error = ToolCallError::from(&failure);
|
||||
assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt);
|
||||
assert!(error.error["PlaytestNotPassed"]
|
||||
.get("screenshots")
|
||||
.is_none());
|
||||
error
|
||||
});
|
||||
let state = direct_tool_bridge_state(temp.path().to_path_buf());
|
||||
let response =
|
||||
compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, outcome);
|
||||
assert_eq!(response["isError"], !passed);
|
||||
let text = response["content"][0]["text"].as_str().unwrap();
|
||||
let model_receipt: Value =
|
||||
serde_json::from_str(&text[text.find('{').unwrap()..]).unwrap();
|
||||
assert_eq!(model_receipt, receipt);
|
||||
assert_eq!(
|
||||
response["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|block| block["type"] == "image")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let summary = model_receipt["summary"].as_str().unwrap();
|
||||
for (index, entry) in evidence.viewport_playtests.iter().enumerate() {
|
||||
let report = &model_receipt["gameplayResults"][index];
|
||||
assert_eq!(
|
||||
report["viewport"],
|
||||
serde_json::to_value(entry.viewport).unwrap()
|
||||
);
|
||||
assert_eq!(report["passed"], entry.result.passed);
|
||||
assert_eq!(
|
||||
report["assertions"],
|
||||
serde_json::to_value(&entry.result.assertions).unwrap()
|
||||
);
|
||||
assert_eq!(report["finalPhase"], "ready");
|
||||
assert_eq!(
|
||||
report["finalSequence"],
|
||||
entry.result.final_sequence.unwrap()
|
||||
);
|
||||
let viewport = report["viewport"].as_str().unwrap();
|
||||
let line = summary
|
||||
.lines()
|
||||
.find(|line| line.starts_with(&format!("{viewport}: gameplayPassed=")))
|
||||
.unwrap();
|
||||
assert!(line.starts_with(&format!(
|
||||
"{viewport}: gameplayPassed={}",
|
||||
entry.result.passed
|
||||
)));
|
||||
if let Some(reason) = entry.result.diagnostics.first() {
|
||||
assert_eq!(report["diagnostics"][0], reason.as_str());
|
||||
assert!(line.contains(reason));
|
||||
}
|
||||
if let Some(assertion) = entry
|
||||
.result
|
||||
.assertions
|
||||
.iter()
|
||||
.find(|assertion| !assertion.passed)
|
||||
{
|
||||
assert!(line.contains(&assertion.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_client_bridge_can_be_enabled_for_a_direct_turn() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
init_local_game_project_at(temporary.path(), "external-direct-turn", "外部直连回合")
|
||||
.unwrap();
|
||||
let bridge = start_external_client_tool_bridge(temporary.path(), false, true)
|
||||
.await
|
||||
.expect("direct Claude bridge");
|
||||
assert!(bridge.state.direct_turn_execution);
|
||||
drop(bridge);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_cancellation_releases_queued_image_without_a_new_post() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -35,12 +35,16 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240;
|
||||
const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl";
|
||||
static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
static EXTERNAL_MCP_SERVER: OnceLock<Mutex<Option<ExternalMcpServer>>> = OnceLock::new();
|
||||
static EXTERNAL_MCP_SERVER: OnceLock<
|
||||
Mutex<std::collections::BTreeMap<PathBuf, ExternalMcpServer>>,
|
||||
> = OnceLock::new();
|
||||
tokio::task_local! {
|
||||
static EXTERNAL_MCP_BRIDGE_URL: String;
|
||||
}
|
||||
|
||||
pub(crate) struct ExternalMcpServer {
|
||||
token: String,
|
||||
_direct_turn_guard: Option<super::direct_tool_bridge::DirectToolBridgeTurnGuard>,
|
||||
_bridge: super::direct_tool_bridge::DirectToolBridge,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
@@ -1759,6 +1763,21 @@ async fn handle_external_mcp_http_request(
|
||||
pub(crate) async fn start_external_mcp_loopback(
|
||||
root: &Path,
|
||||
controlled_web_search: bool,
|
||||
) -> Result<(String, String), String> {
|
||||
start_external_mcp_loopback_with_mode(root, controlled_web_search, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_external_mcp_loopback_for_direct_turn(
|
||||
root: &Path,
|
||||
controlled_web_search: bool,
|
||||
) -> Result<(String, String), String> {
|
||||
start_external_mcp_loopback_with_mode(root, controlled_web_search, true).await
|
||||
}
|
||||
|
||||
async fn start_external_mcp_loopback_with_mode(
|
||||
root: &Path,
|
||||
controlled_web_search: bool,
|
||||
direct_turn_execution: bool,
|
||||
) -> Result<(String, String), String> {
|
||||
let root = validate_direct_tools_project_root(root)?;
|
||||
let session = current_platform_session()
|
||||
@@ -1771,12 +1790,20 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?;
|
||||
let bridge =
|
||||
super::direct_tool_bridge::start_external_client_tool_bridge(&root, controlled_web_search)
|
||||
.await?;
|
||||
let bridge = super::direct_tool_bridge::start_external_client_tool_bridge(
|
||||
&root,
|
||||
controlled_web_search,
|
||||
direct_turn_execution,
|
||||
)
|
||||
.await?;
|
||||
let direct_turn_guard = if direct_turn_execution {
|
||||
Some(bridge.begin_user_turn()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let state = ExternalMcpHttpState {
|
||||
bridge_url: bridge.url().to_string(),
|
||||
root,
|
||||
root: root.clone(),
|
||||
token: token.clone(),
|
||||
session_user_id: session.user_id,
|
||||
session_identity_generation: session.identity_generation,
|
||||
@@ -1790,24 +1817,40 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
let url = format!("http://127.0.0.1:{}{route}", address.port());
|
||||
let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None));
|
||||
let registry =
|
||||
EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
|
||||
let mut guard = registry
|
||||
.lock()
|
||||
.map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?;
|
||||
if let Some(previous) = guard.take() {
|
||||
drop(previous);
|
||||
}
|
||||
*guard = Some(ExternalMcpServer {
|
||||
_bridge: bridge,
|
||||
task,
|
||||
});
|
||||
guard.insert(
|
||||
root,
|
||||
ExternalMcpServer {
|
||||
token: token.clone(),
|
||||
_direct_turn_guard: direct_turn_guard,
|
||||
_bridge: bridge,
|
||||
task,
|
||||
},
|
||||
);
|
||||
Ok((url, token))
|
||||
}
|
||||
|
||||
pub(crate) fn stop_external_mcp_loopback() {
|
||||
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
|
||||
if let Ok(mut guard) = registry.lock() {
|
||||
guard.take();
|
||||
guard.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return;
|
||||
};
|
||||
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
|
||||
if let Ok(mut guard) = registry.lock() {
|
||||
if guard.get(&root).is_some_and(|server| server.token == token) {
|
||||
guard.remove(&root);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2180,10 +2223,13 @@ mod tests {
|
||||
.unwrap();
|
||||
std::fs::create_dir(project.path().join(".agent")).unwrap();
|
||||
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||
let bridge =
|
||||
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge(
|
||||
project.path(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for enabled in [false, true, false, true] {
|
||||
crate::builtin_plugins::set_enabled(
|
||||
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
|
||||
@@ -2281,10 +2327,13 @@ mod tests {
|
||||
let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-");
|
||||
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
||||
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||
let bridge =
|
||||
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge(
|
||||
project.path(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (cocos_enabled, unity_enabled) in
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
{
|
||||
@@ -2419,10 +2468,13 @@ mod tests {
|
||||
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
|
||||
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
||||
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||
let bridge =
|
||||
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge(
|
||||
project.path(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for enabled in [false, true, false, true] {
|
||||
crate::builtin_plugins::set_enabled(
|
||||
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
|
||||
|
||||
@@ -395,6 +395,137 @@ async fn finish_async(
|
||||
.map_err(|_| "验证回执任务退出".to_string())?
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameplayViewportEvidence {
|
||||
viewport: crate::browser::BrowserValidationViewport,
|
||||
passed: bool,
|
||||
diagnostics: Vec<String>,
|
||||
assertions: Vec<crate::browser::BrowserPlaytestAssertion>,
|
||||
initial_phase: Option<crate::browser::BrowserPlaytestPhase>,
|
||||
initial_sequence: Option<u64>,
|
||||
initial_level: Option<u64>,
|
||||
final_phase: Option<crate::browser::BrowserPlaytestPhase>,
|
||||
final_sequence: Option<u64>,
|
||||
final_level: Option<u64>,
|
||||
}
|
||||
|
||||
/// 只投影模型修复所需的玩法事实;完整报告继续留在宿主私有目录。
|
||||
pub(crate) fn browser_tool_result(
|
||||
root: &Path,
|
||||
evidence: &crate::browser::BrowserValidationResult,
|
||||
scenario: Option<crate::browser::BrowserPlaytestScenario>,
|
||||
sequence: usize,
|
||||
) -> Value {
|
||||
use crate::browser::BrowserValidationViewport::{Desktop, Mobile};
|
||||
|
||||
let gameplay_results = if scenario.is_some() {
|
||||
[Desktop, Mobile]
|
||||
.into_iter()
|
||||
.map(|viewport| {
|
||||
let result = evidence
|
||||
.viewport_playtests
|
||||
.iter()
|
||||
.find(|entry| entry.viewport == viewport)
|
||||
.map(|entry| &entry.result);
|
||||
GameplayViewportEvidence {
|
||||
viewport,
|
||||
passed: result.is_some_and(|result| result.passed),
|
||||
diagnostics: result
|
||||
.map(|result| {
|
||||
result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|message| truncate_agent_runtime_text(message, 511))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
vec![prompt_text!("direct.browser.gameplayMissing").into()]
|
||||
}),
|
||||
assertions: result
|
||||
.map(|result| result.assertions.clone())
|
||||
.unwrap_or_default(),
|
||||
initial_phase: result.and_then(|result| result.initial_phase),
|
||||
initial_sequence: result.and_then(|result| result.initial_sequence),
|
||||
initial_level: result.and_then(|result| result.initial_level),
|
||||
final_phase: result.and_then(|result| result.final_phase),
|
||||
final_sequence: result.and_then(|result| result.final_sequence),
|
||||
final_level: result.and_then(|result| result.final_level),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let gameplay_summary = if scenario.is_none() {
|
||||
prompt_text!("direct.browser.gameplayNotRun").to_string()
|
||||
} else {
|
||||
let lines = gameplay_results
|
||||
.iter()
|
||||
.map(|result| {
|
||||
format!(
|
||||
prompt_text!("direct.browser.gameplayViewport"),
|
||||
viewport = match result.viewport {
|
||||
Desktop => "desktop",
|
||||
Mobile => "mobile",
|
||||
},
|
||||
passed = result.passed,
|
||||
diagnostic = result
|
||||
.diagnostics
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or(prompt_text!("direct.browser.gameplayNoDiagnostic")),
|
||||
assertion = result
|
||||
.assertions
|
||||
.iter()
|
||||
.find(|assertion| !assertion.passed)
|
||||
.map(|assertion| assertion.name.as_str())
|
||||
.unwrap_or(prompt_text!("direct.browser.gameplayNoUnmetAssertion")),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
format!(
|
||||
prompt_text!("direct.browser.gameplayEvidence"),
|
||||
lines.join("\n")
|
||||
)
|
||||
};
|
||||
let summary = format!(
|
||||
"{}\n{}",
|
||||
render_direct_browser_tool_evidence(root, evidence, sequence),
|
||||
gameplay_summary
|
||||
);
|
||||
let screenshots: Vec<_> = evidence
|
||||
.viewport_results
|
||||
.iter()
|
||||
.filter_map(|viewport| {
|
||||
viewport
|
||||
.screenshot_path
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|path| path.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect();
|
||||
let passed = evidence.passed
|
||||
&& scenario.is_none_or(|scenario| {
|
||||
crate::browser::required_viewport_playtests_passed(
|
||||
scenario,
|
||||
&evidence.viewport_playtests,
|
||||
)
|
||||
});
|
||||
json!({
|
||||
"kind":"browser", "mode":if scenario.is_some() {"gameplay"} else {"visual"},
|
||||
"scenario":scenario, "passed":passed,
|
||||
"scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint),
|
||||
"summary":summary, "screenshots":screenshots,
|
||||
"reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|path|path.to_string_lossy().replace('\\', "/")),
|
||||
"visualViewports":["desktop","mobile"],
|
||||
"gameplayViewports":evidence.viewport_playtests.iter().map(|entry|entry.viewport).collect::<Vec<_>>(),
|
||||
"gameplayResults":gameplay_results,
|
||||
"coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn run_browser(root: &Path, arguments: &Value) -> Result<Value, String> {
|
||||
run_browser_with_budget(root, arguments, true).await
|
||||
}
|
||||
@@ -466,33 +597,9 @@ async fn run_browser_with_budget(
|
||||
.await;
|
||||
let (result, passed) = match evidence {
|
||||
Ok(evidence) => {
|
||||
let screenshots: Vec<_> = evidence
|
||||
.viewport_results
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
v.screenshot_path
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect();
|
||||
let gameplay_passed = scenario.is_none_or(|scenario| {
|
||||
crate::browser::required_viewport_playtests_passed(
|
||||
scenario,
|
||||
&evidence.viewport_playtests,
|
||||
)
|
||||
});
|
||||
let passed = evidence.passed && gameplay_passed;
|
||||
let summary = render_direct_browser_tool_evidence(root, &evidence, sequence);
|
||||
(
|
||||
json!({"kind":"browser", "mode":input.mode, "scenario":scenario,
|
||||
"scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), "summary":summary, "screenshots":screenshots,
|
||||
"reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|p|p.to_string_lossy().replace('\\', "/")),
|
||||
"visualViewports":["desktop","mobile"],
|
||||
"gameplayViewports":evidence.viewport_playtests.iter().map(|v|v.viewport).collect::<Vec<_>>(),
|
||||
"coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"}}),
|
||||
passed,
|
||||
)
|
||||
let result = browser_tool_result(root, &evidence, scenario, sequence);
|
||||
let passed = result["passed"] == true;
|
||||
(result, passed)
|
||||
}
|
||||
Err(error) => (
|
||||
json!({"mode":input.mode,"needsReconciliation":error.starts_with("browser-cleanup-unconfirmed:"),"error":truncate_agent_runtime_text(&error, 1800)}),
|
||||
@@ -599,9 +706,147 @@ pub(super) async fn run_command(root: &Path, arguments: &Value) -> Result<Value,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn browser_evidence_fixture(root: &Path) -> crate::browser::BrowserValidationResult {
|
||||
let mut viewports = Vec::new();
|
||||
let mut playtests = Vec::new();
|
||||
for viewport in ["desktop", "mobile"] {
|
||||
viewports.push(json!({
|
||||
"viewport":viewport, "width":1280, "height":720,
|
||||
"finalUrl":"http://127.0.0.1/", "title":"fixture", "readyState":"complete",
|
||||
"visibleTextSummary":"", "visibleTextCharacterCount":0, "domCharacterCount":0,
|
||||
"expectedText":[], "consoleErrors":[], "consoleWarnings":[], "exceptions":[],
|
||||
"failedRequests":[], "canvases":[], "diagnostics":[], "passed":true,
|
||||
"blockedPopupCount":0, "blockedDialogCount":0, "blockedDownloadCount":0,
|
||||
"blockedPermissionCount":0, "blockedServiceWorkerCount":0,
|
||||
"screenshotPath":root.join(format!("{viewport}.png"))
|
||||
}));
|
||||
playtests.push(json!({"viewport":viewport, "result":{
|
||||
"scenario":"generic-v1", "scenarioFingerprint":"fixture", "passed":true,
|
||||
"initialSequence":0, "initialPhase":"ready", "initialLevel":1,
|
||||
"finalSequence":3, "finalPhase":"ready", "finalLevel":1,
|
||||
"assertions":[
|
||||
{"name":"start-control-clicked", "passed":true},
|
||||
{"name":"start-phase-playing", "passed":true},
|
||||
{"name":"restart-control-clicked", "passed":true}
|
||||
], "diagnostics":[]
|
||||
}}));
|
||||
}
|
||||
serde_json::from_value(json!({
|
||||
"schemaVersion":"browser-validation.v1", "url":"http://127.0.0.1/",
|
||||
"browser":{"kind":"chrome", "product":"fixture", "protocolVersion":"1.3"},
|
||||
"passed":true, "viewportResults":viewports, "viewportPlaytests":playtests,
|
||||
"diagnostics":[], "completedAtUnixMs":1,
|
||||
"evidence":{"root":root, "reportPath":root.join("validation.json")}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn failed_browser_evidence_fixture(
|
||||
root: &Path,
|
||||
) -> crate::browser::BrowserValidationResult {
|
||||
let mut evidence = browser_evidence_fixture(root);
|
||||
evidence.passed = false;
|
||||
for (index, entry) in evidence.viewport_playtests.iter_mut().enumerate() {
|
||||
entry.result.passed = false;
|
||||
entry.result.final_phase = Some(crate::browser::BrowserPlaytestPhase::Ready);
|
||||
entry.result.final_sequence = Some(index as u64);
|
||||
entry.result.diagnostics = vec![if index == 0 {
|
||||
"固定试玩控件 start 处于 disabled 状态".into()
|
||||
} else {
|
||||
"generic-v1 start 后必须进入 playing".into()
|
||||
}];
|
||||
for (assertion_index, assertion) in entry.result.assertions.iter_mut().enumerate() {
|
||||
assertion.passed = index == 1 && assertion_index == 0;
|
||||
}
|
||||
}
|
||||
evidence
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_projection_distinguishes_gameplay_success_visual_only_and_missing_viewport() {
|
||||
use crate::browser::BrowserPlaytestScenario::GenericV1;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for mode in ["gameplay", "visual", "missing-mobile"] {
|
||||
let mut evidence = browser_evidence_fixture(root.path());
|
||||
let scenario = if mode == "visual" {
|
||||
evidence.viewport_playtests.clear();
|
||||
None
|
||||
} else {
|
||||
Some(GenericV1)
|
||||
};
|
||||
if mode == "missing-mobile" {
|
||||
evidence.viewport_playtests.pop();
|
||||
evidence.passed = false;
|
||||
}
|
||||
let receipt = browser_tool_result(root.path(), &evidence, scenario, 1);
|
||||
let summary = receipt["summary"].as_str().unwrap();
|
||||
assert_eq!(receipt["passed"], mode != "missing-mobile");
|
||||
if mode == "visual" {
|
||||
assert_eq!(receipt["gameplayResults"], json!([]));
|
||||
assert!(summary
|
||||
.lines()
|
||||
.any(|line| line.starts_with("玩法检查:未执行")));
|
||||
assert_eq!(receipt["coverage"], "visual-only");
|
||||
continue;
|
||||
}
|
||||
for (index, viewport) in ["desktop", "mobile"].iter().enumerate() {
|
||||
let result = &receipt["gameplayResults"][index];
|
||||
assert_eq!(result["viewport"], *viewport);
|
||||
if mode == "missing-mobile" && index == 1 {
|
||||
assert_eq!(result["passed"], false);
|
||||
assert!(result["diagnostics"][0].as_str().unwrap().contains("缺失"));
|
||||
assert!(summary.contains("mobile: gameplayPassed=false"));
|
||||
assert_eq!(result["assertions"], json!([]));
|
||||
assert_eq!(result["finalPhase"], Value::Null);
|
||||
} else {
|
||||
assert_eq!(result["passed"], true);
|
||||
assert_eq!(result["diagnostics"], json!([]));
|
||||
assert!(result["assertions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|a| a["passed"] == true));
|
||||
assert!(summary.contains(&format!("{viewport}: gameplayPassed=true")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_projection_bounds_diagnostics_without_losing_either_blocker() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut evidence = failed_browser_evidence_fixture(root.path());
|
||||
for viewport in &mut evidence.viewport_playtests {
|
||||
viewport
|
||||
.result
|
||||
.diagnostics
|
||||
.extend((0..8).map(|_| "额外诊断".repeat(200)));
|
||||
}
|
||||
let receipt = browser_tool_result(
|
||||
root.path(),
|
||||
&evidence,
|
||||
Some(crate::browser::BrowserPlaytestScenario::GenericV1),
|
||||
1,
|
||||
);
|
||||
for (index, entry) in evidence.viewport_playtests.iter().enumerate() {
|
||||
let diagnostics = receipt["gameplayResults"][index]["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(diagnostics.len(), 4);
|
||||
assert_eq!(diagnostics[0], entry.result.diagnostics[0]);
|
||||
assert!(diagnostics
|
||||
.iter()
|
||||
.all(|d| d.as_str().unwrap().chars().count() <= 512));
|
||||
assert!(receipt["summary"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(&entry.result.diagnostics[0]));
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
|
||||
@@ -567,6 +567,23 @@ pub(crate) fn upload_local_asset_at(
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
upload_local_asset_at_with_category(root, file_name, media_type, bytes, None)
|
||||
}
|
||||
|
||||
/// 带**显式入口栏目**的上传:只给 GUI 工具栏上传用(前端 `targetCategory`)。
|
||||
///
|
||||
/// 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`),
|
||||
/// 与栏目不是同一套词汇:在栏目画布里上传素材时,用户选定的栏目才是归属真相,
|
||||
/// 否则素材会落进「待归类」、在上传它的那一栏里看不见。
|
||||
/// 取值校验与失败关闭沿用 [`register_local_asset_entry_with_category`]:
|
||||
/// 非法值报错、绝不回退到 kind 派生;其它调用方继续走 [`upload_local_asset_at`]。
|
||||
pub(crate) fn upload_local_asset_at_with_category(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
bytes: &[u8],
|
||||
target_category: Option<&str>,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
if bytes.is_empty() {
|
||||
return Err("上传文件不能为空".to_string());
|
||||
@@ -584,7 +601,7 @@ pub(crate) fn upload_local_asset_at(
|
||||
}
|
||||
crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?;
|
||||
|
||||
register_local_asset_entry(
|
||||
register_local_asset_entry_with_category(
|
||||
root,
|
||||
&relative_path,
|
||||
uploaded_asset_kind(file_name, media_type),
|
||||
@@ -602,6 +619,7 @@ pub(crate) fn upload_local_asset_at(
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
target_category,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2375,6 +2393,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue 359:工具栏上传带上入口栏目,素材才不会落进「待归类」。
|
||||
///
|
||||
/// 上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;
|
||||
/// 在栏目画布(如「角色与对象」)里上传时栏目才是归属真相。不传时保持既有行为,
|
||||
/// 非法值失败关闭且不留下半成品登记。
|
||||
#[test]
|
||||
fn upload_registers_into_the_explicit_entry_category() {
|
||||
fn upload_category(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
|
||||
read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.expect("uploaded asset is registered")
|
||||
.category
|
||||
}
|
||||
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "upload-category-test", "上传入口栏目")
|
||||
.expect("init project");
|
||||
|
||||
let uploaded = upload_local_asset_at_with_category(
|
||||
root,
|
||||
"hero.png",
|
||||
"image/png",
|
||||
b"png-bytes",
|
||||
Some("character"),
|
||||
)
|
||||
.expect("upload with an entry category");
|
||||
assert_eq!(
|
||||
upload_category(root, &uploaded.id),
|
||||
GameCreationAppAssetCategory::Character
|
||||
);
|
||||
|
||||
let plain = upload_local_asset_at(root, "plain.png", "image/png", b"png-bytes")
|
||||
.expect("upload without an entry category");
|
||||
assert_eq!(
|
||||
upload_category(root, &plain.id),
|
||||
GameCreationAppAssetCategory::Unclassified
|
||||
);
|
||||
|
||||
// 非法入口栏目(栏目侧伪值 `version` 不在枚举里)失败关闭:不新增登记。
|
||||
let assets_before = read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.assets
|
||||
.len();
|
||||
assert!(upload_local_asset_at_with_category(
|
||||
root,
|
||||
"bad.png",
|
||||
"image/png",
|
||||
b"png-bytes",
|
||||
Some("version"),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.assets
|
||||
.len(),
|
||||
assets_before
|
||||
);
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
#[test]
|
||||
fn canvas_export_asset_kind_is_always_canonical() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::discovery::system_browser_candidates;
|
||||
use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX};
|
||||
#[cfg(windows)]
|
||||
use crate::process_session::WindowsProcessJob;
|
||||
|
||||
const OWNER_FILE: &str = "owner.json";
|
||||
const OWNER_SCHEMA: &str = "agc-browser-process.v1";
|
||||
@@ -34,13 +36,17 @@ pub(super) struct BrowserProcessOwner {
|
||||
|
||||
/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写,
|
||||
/// 该目录之后按旧残留只删不杀。
|
||||
pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) {
|
||||
pub(super) fn write_browser_process_owner(
|
||||
temp_root: &Path,
|
||||
browser_pid: u32,
|
||||
executable: &Path,
|
||||
) -> bool {
|
||||
let identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(browser_pid);
|
||||
let owner_identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(std::process::id());
|
||||
let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let owner = BrowserProcessOwner {
|
||||
schema_version: OWNER_SCHEMA.into(),
|
||||
@@ -52,9 +58,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex
|
||||
created_unix_ms: super::evidence::unix_time_ms(),
|
||||
};
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&owner) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let _ = fs::write(temp_root.join(OWNER_FILE), bytes);
|
||||
fs::write(temp_root.join(OWNER_FILE), bytes).is_ok()
|
||||
}
|
||||
|
||||
fn read_browser_process_owner(dir: &Path) -> Option<BrowserProcessOwner> {
|
||||
@@ -115,34 +121,142 @@ fn trusted_browser_executable(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
|
||||
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
|
||||
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
|
||||
/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置
|
||||
/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。
|
||||
/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC
|
||||
/// 实例或用户 Edge 误认成本轮浏览器。
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool {
|
||||
// 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对
|
||||
// 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。
|
||||
fn windows_process_command_line(pid: u32) -> Option<String> {
|
||||
use std::ffi::c_void;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
struct UnicodeString {
|
||||
length: u16,
|
||||
maximum_length: u16,
|
||||
buffer: *const u16,
|
||||
}
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
unsafe extern "system" {
|
||||
fn NtQueryInformationProcess(
|
||||
process: *mut c_void,
|
||||
information_class: u32,
|
||||
information: *mut c_void,
|
||||
information_length: u32,
|
||||
return_length: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) };
|
||||
if process.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut required = 0;
|
||||
let _ =
|
||||
unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) };
|
||||
if required == 0 || required > 64 * 1024 {
|
||||
unsafe { CloseHandle(process) };
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u8; required as usize];
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process,
|
||||
60,
|
||||
buffer.as_mut_ptr().cast(),
|
||||
required,
|
||||
&mut required,
|
||||
)
|
||||
};
|
||||
unsafe { CloseHandle(process) };
|
||||
if status != 0 {
|
||||
return None;
|
||||
}
|
||||
let command_line = unsafe { &*(buffer.as_ptr().cast::<UnicodeString>()) };
|
||||
if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let text = unsafe {
|
||||
std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2)
|
||||
};
|
||||
String::from_utf16(text).ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_command_line_arguments(command_line: &str) -> Option<Vec<String>> {
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::UI::Shell::CommandLineToArgvW;
|
||||
|
||||
let wide = command_line
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let mut count = 0;
|
||||
let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) };
|
||||
if argv.is_null() || count < 0 {
|
||||
if !argv.is_null() {
|
||||
unsafe { LocalFree(argv.cast()) };
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let result = unsafe { std::slice::from_raw_parts(argv, count as usize) }
|
||||
.iter()
|
||||
.map(|argument| {
|
||||
if argument.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut length = 0;
|
||||
unsafe {
|
||||
while *argument.add(length) != 0 {
|
||||
length += 1;
|
||||
}
|
||||
String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok()
|
||||
}
|
||||
})
|
||||
.collect::<Option<Vec<_>>>();
|
||||
unsafe { LocalFree(argv.cast()) };
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool {
|
||||
const MARKER: &str = "--user-data-dir=";
|
||||
let Some(arguments) = windows_command_line_arguments(command_line) else {
|
||||
return false;
|
||||
};
|
||||
arguments.iter().enumerate().any(|(index, argument)| {
|
||||
let value = argument.strip_prefix(MARKER).or_else(|| {
|
||||
(argument == "--user-data-dir")
|
||||
.then(|| arguments.get(index + 1).map(String::as_str))
|
||||
.flatten()
|
||||
});
|
||||
value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
// SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
let mut buffer = [0u16; 1024];
|
||||
let mut length = buffer.len() as u32;
|
||||
// SAFETY: buffer 可写,length 先传入容量、返回实际长度。
|
||||
let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
|
||||
// SAFETY: handle 是本函数持有的合法句柄。
|
||||
unsafe { CloseHandle(handle) };
|
||||
if okay == 0 || length == 0 {
|
||||
return false;
|
||||
}
|
||||
let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize]));
|
||||
same_executable_path(&actual, expected)
|
||||
&& windows_process_command_line(pid)
|
||||
.is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -181,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> {
|
||||
pub(super) fn kill_browser_process_tree(
|
||||
root_pid: u32,
|
||||
expected_identity: &str,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判
|
||||
@@ -190,15 +307,17 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
|
||||
for _ in 0..TREE_KILL_MAX_PASSES {
|
||||
let snapshot = windows_process_snapshot()?;
|
||||
let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid);
|
||||
if root_present && process_identity_matches(root_pid, expected_identity) {
|
||||
if root_present {
|
||||
if !process_identity_matches(root_pid, expected_identity) {
|
||||
return Err("browser-sweep-root-identity-mismatch".into());
|
||||
}
|
||||
// root 仍是目标浏览器:发现并追踪当前整棵子树。
|
||||
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
|
||||
track_process(&mut tracked, pid);
|
||||
}
|
||||
} else if !root_present {
|
||||
} else {
|
||||
// root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父
|
||||
// PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认
|
||||
// 复用者的子进程。
|
||||
// PID 上的孤儿子进程。PID 已被复用时不会进入此分支。
|
||||
for (pid, ppid) in &snapshot {
|
||||
if *ppid == root_pid {
|
||||
track_process(&mut tracked, *pid);
|
||||
@@ -323,6 +442,62 @@ fn windows_process_tree_pids_from(
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn ensure_browser_tree_in_job(
|
||||
root_pid: u32,
|
||||
root_identity: &str,
|
||||
job: &WindowsProcessJob,
|
||||
) -> Result<(), String> {
|
||||
for _ in 0..TREE_KILL_MAX_PASSES {
|
||||
let snapshot = windows_process_snapshot()?;
|
||||
if !snapshot.iter().any(|(pid, _)| *pid == root_pid)
|
||||
|| !process_identity_matches(root_pid, root_identity)
|
||||
{
|
||||
return Err("browser-job-root-identity-unconfirmed".into());
|
||||
}
|
||||
let tree = windows_process_tree_pids_from(&snapshot, root_pid)?;
|
||||
if tree.is_empty() {
|
||||
return Err("browser-job-root-exited-before-coverage".into());
|
||||
}
|
||||
let mut members = Vec::with_capacity(tree.len());
|
||||
for pid in tree {
|
||||
let identity = if pid == root_pid {
|
||||
root_identity.to_string()
|
||||
} else {
|
||||
crate::process_identity::external_agent_runner_process_start_identity(pid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())?
|
||||
};
|
||||
members.push((pid, identity));
|
||||
}
|
||||
for (pid, identity) in members {
|
||||
if !process_identity_matches(pid, &identity) {
|
||||
return Err("browser-job-process-identity-changed".into());
|
||||
}
|
||||
if !job.contains_pid(pid)? {
|
||||
job.assign_pid(pid)?;
|
||||
}
|
||||
if !process_identity_matches(pid, &identity) {
|
||||
return Err("browser-job-process-identity-changed".into());
|
||||
}
|
||||
}
|
||||
|
||||
let verified = windows_process_snapshot()?;
|
||||
let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?;
|
||||
if process_identity_matches(root_pid, root_identity)
|
||||
&& !verified_tree.is_empty()
|
||||
&& verified_tree
|
||||
.iter()
|
||||
.all(|pid| job.contains_pid(*pid).unwrap_or(false))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
|
||||
}
|
||||
Err("browser-job-tree-coverage-unconfirmed".into())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_terminate_process(pid: u32) {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
@@ -468,6 +643,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_write_failure_leaves_no_partial_ownership_record() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let missing_root = root.path().join("missing");
|
||||
assert!(!write_browser_process_owner(
|
||||
&missing_root,
|
||||
std::process::id(),
|
||||
&std::env::current_exe().unwrap(),
|
||||
));
|
||||
assert!(!missing_root.join(OWNER_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -548,6 +735,90 @@ mod tests {
|
||||
assert!(trusted_browser_executable(&installed));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_browser_cleanup_requires_the_exact_profile_argument() {
|
||||
let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#);
|
||||
assert!(command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(command_line_contains_profile(
|
||||
r#""msedge.exe" "--user-data-dir=C:\Users\tester\App Data\ga-browser-1\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(!command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(!command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#,
|
||||
profile
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn browser_job_adopts_children_created_before_assignment() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("cmd.exe")
|
||||
.args([
|
||||
"/c",
|
||||
"ping",
|
||||
"127.0.0.1",
|
||||
"-n",
|
||||
"60",
|
||||
"&",
|
||||
"ping",
|
||||
"127.0.0.1",
|
||||
"-n",
|
||||
"60",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn fixture");
|
||||
let pid = child.id();
|
||||
let identity = crate::process_identity::external_agent_runner_process_start_identity(pid)
|
||||
.expect("fixture identity")
|
||||
.expect("fixture identity present");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job");
|
||||
ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree");
|
||||
let snapshot = windows_process_snapshot().expect("snapshot after adoption");
|
||||
let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption");
|
||||
assert!(tree
|
||||
.iter()
|
||||
.all(|member| job.contains_pid(*member).expect("job membership")));
|
||||
job.terminate().expect("terminate fixture job");
|
||||
let _ = child.wait();
|
||||
assert!(job.is_empty().expect("fixture job empty"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn kill_browser_process_tree_rejects_root_identity_mismatch() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("cmd.exe")
|
||||
.args(["/c", "ping", "127.0.0.1", "-n", "60"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn fixture");
|
||||
let pid = child.id();
|
||||
let result = kill_browser_process_tree(pid, "not-the-fixture-identity");
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert_eq!(
|
||||
result.expect_err("identity mismatch must fail closed"),
|
||||
"browser-sweep-root-identity-mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn kill_browser_process_tree_reaps_fixture_tree() {
|
||||
@@ -600,10 +871,8 @@ mod tests {
|
||||
fn live_process_executable_matches_current_process_image() {
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let profile = Path::new("C:\\fixture\\ga-browser-x\\profile");
|
||||
// Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程
|
||||
// 不具备该标记,正例只在 Windows 断言。
|
||||
#[cfg(windows)]
|
||||
assert!(live_process_executable_matches(
|
||||
// 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。
|
||||
assert!(!live_process_executable_matches(
|
||||
std::process::id(),
|
||||
&exe,
|
||||
profile
|
||||
|
||||
@@ -1514,6 +1514,41 @@ async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_reports_start_click_without_playing_phase() {
|
||||
let result = run_stable_generic_viewport_fixture(false, Some(
|
||||
r#"<button type="button" data-playtest-id="start" data-start-phase="ready">Start</button>"#,
|
||||
)).await;
|
||||
assert!(!result.passed);
|
||||
assert!(result
|
||||
.viewport_results
|
||||
.iter()
|
||||
.all(|viewport| viewport.passed));
|
||||
for entry in &result.viewport_playtests {
|
||||
let playtest = &entry.result;
|
||||
assert!(!playtest.passed);
|
||||
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
|
||||
assert_eq!(playtest.final_sequence, Some(1));
|
||||
for (name, expected) in [
|
||||
("start-control-clicked", true),
|
||||
("start-phase-playing", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
playtest
|
||||
.assertions
|
||||
.iter()
|
||||
.find(|assertion| assertion.name == name)
|
||||
.unwrap()
|
||||
.passed,
|
||||
expected
|
||||
);
|
||||
}
|
||||
let reason = &playtest.diagnostics[0];
|
||||
assert!(reason.contains("必须进入 playing"), "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_stable_generic_viewport_fixture(
|
||||
mobile_action_broken: bool,
|
||||
start_markup: Option<&str>,
|
||||
@@ -1556,7 +1591,7 @@ async fn run_stable_generic_viewport_fixture(
|
||||
};
|
||||
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
|
||||
if (event.isTrusted && state.phase === 'ready') {
|
||||
advance(() => { state.phase = 'playing'; });
|
||||
advance(() => { state.phase = event.currentTarget.dataset.startPhase || 'playing'; });
|
||||
}
|
||||
});
|
||||
const primary = document.querySelector('[data-playtest-id="primary-action"]');
|
||||
@@ -1641,6 +1676,57 @@ async fn run_stable_generic_viewport_fixture(
|
||||
let persisted: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&result.evidence.report_path).unwrap()).unwrap();
|
||||
assert_eq!(persisted["viewportPlaytests"].as_array().unwrap().len(), 2);
|
||||
let receipt = crate::agent::browser_tool_result(
|
||||
evidence.path(),
|
||||
&result,
|
||||
Some(BrowserPlaytestScenario::GenericV1),
|
||||
1,
|
||||
);
|
||||
assert_eq!(receipt["passed"], persisted["passed"]);
|
||||
for (index, entry) in persisted["viewportPlaytests"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let projected = &receipt["gameplayResults"][index];
|
||||
assert_eq!(projected["viewport"], entry["viewport"]);
|
||||
let viewport = entry["viewport"].as_str().unwrap();
|
||||
let line = receipt["summary"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.lines()
|
||||
.find(|line| line.starts_with(&format!("{viewport}: gameplayPassed=")))
|
||||
.unwrap();
|
||||
assert!(line.starts_with(&format!(
|
||||
"{viewport}: gameplayPassed={}",
|
||||
entry["result"]["passed"]
|
||||
)));
|
||||
for field in [
|
||||
"passed",
|
||||
"assertions",
|
||||
"initialPhase",
|
||||
"initialSequence",
|
||||
"initialLevel",
|
||||
"finalPhase",
|
||||
"finalSequence",
|
||||
"finalLevel",
|
||||
] {
|
||||
assert_eq!(projected[field], entry["result"][field], "{field}");
|
||||
}
|
||||
if let Some(reason) = entry["result"]["diagnostics"][0].as_str() {
|
||||
assert_eq!(projected["diagnostics"][0], reason);
|
||||
assert!(line.contains(reason));
|
||||
}
|
||||
if let Some(assertion) = entry["result"]["assertions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|assertion| assertion["passed"] == false)
|
||||
{
|
||||
assert!(line.contains(assertion["name"].as_str().unwrap()));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -493,12 +493,19 @@ pub(crate) fn upload_local_asset(
|
||||
file_name: String,
|
||||
media_type: String,
|
||||
bytes: Vec<u8>,
|
||||
target_category: Option<String>,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.upload")?;
|
||||
let _lock = acquire_project_write_lock(root, "asset.upload")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes)
|
||||
upload_local_asset_at_with_category(
|
||||
root,
|
||||
file_name.trim(),
|
||||
media_type.trim(),
|
||||
&bytes,
|
||||
target_category.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -311,6 +311,13 @@ async fn host_npm(
|
||||
fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
if error.contains("未发现可用的") {
|
||||
"web-preflight-browser-missing"
|
||||
} else if error.starts_with("browser-recovery-") {
|
||||
"web-preflight-browser-recovery-failed"
|
||||
} else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid")
|
||||
{
|
||||
"web-preflight-browser-environment-failed"
|
||||
} else if error.starts_with("browser-launch-failed:") {
|
||||
"web-preflight-browser-launch-failed"
|
||||
} else if error.contains("启动浏览器超时") {
|
||||
"web-preflight-browser-launch-timeout"
|
||||
} else if error.contains("启动浏览器失败") {
|
||||
@@ -342,6 +349,13 @@ fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_validation_diagnostic(error: &str) -> Option<&str> {
|
||||
(error.starts_with("browser-recovery-")
|
||||
|| error.starts_with("browser-launch-failed:")
|
||||
|| error.starts_with("browser-cleanup-unconfirmed:"))
|
||||
.then_some(error)
|
||||
}
|
||||
|
||||
pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let started = Instant::now();
|
||||
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
|
||||
@@ -362,18 +376,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
|
||||
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
|
||||
}).await;
|
||||
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
|
||||
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
|
||||
let result = validation
|
||||
.map_err(|error| {
|
||||
let code = browser_validation_failure_code(&error);
|
||||
browser_validation_diagnostic(&error)
|
||||
.map(|diagnostic| format!("{code};diagnostic={diagnostic}"))
|
||||
.unwrap_or_else(|| code.to_string())
|
||||
})?;
|
||||
if !result.passed || result.viewport_results.len() != 2 {
|
||||
return Err("web-preflight-page-check-failed".into());
|
||||
}
|
||||
let mut pngs = Vec::new();
|
||||
for viewport in result.viewport_results {
|
||||
let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); }
|
||||
image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?;
|
||||
pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()}));
|
||||
let bytes = fs::read(&viewport.screenshot_path)
|
||||
.map_err(|_| "web-preflight-screenshot-missing")?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 {
|
||||
return Err("web-preflight-screenshot-invalid".into());
|
||||
}
|
||||
image::load_from_memory(&bytes)
|
||||
.map_err(|_| "web-preflight-screenshot-invalid")?;
|
||||
pngs.push(json!({
|
||||
"viewport": viewport.viewport,
|
||||
"passed": viewport.passed,
|
||||
"pngBytes": bytes.len(),
|
||||
}));
|
||||
}
|
||||
Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}}))
|
||||
}.await;
|
||||
let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code}));
|
||||
Ok::<_, String>(json!({
|
||||
"schemaVersion": "agc-web-creation-preflight.v1",
|
||||
"status": "ready",
|
||||
"runtime": {
|
||||
"source": runtime.source,
|
||||
"nodeVersion": node,
|
||||
"npmVersion": npm,
|
||||
},
|
||||
"build": {"status": "ready", "kind": "npm-node-fixture"},
|
||||
"browser": {"status": "ready", "viewports": pngs},
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
let mut result = run.unwrap_or_else(|error| {
|
||||
let (code, diagnostic) = error
|
||||
.split_once(";diagnostic=")
|
||||
.map_or((error.as_str(), None), |(code, diagnostic)| {
|
||||
(code, Some(diagnostic))
|
||||
});
|
||||
let mut blocked = json!({
|
||||
"schemaVersion": "agc-web-creation-preflight.v1",
|
||||
"status": "blocked",
|
||||
"code": code,
|
||||
});
|
||||
if let Some(diagnostic) = diagnostic {
|
||||
blocked["diagnostic"] = json!(diagnostic);
|
||||
}
|
||||
blocked
|
||||
});
|
||||
result["elapsedMs"] = json!(started.elapsed().as_millis());
|
||||
result
|
||||
}
|
||||
@@ -382,9 +438,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
pub(crate) async fn preflight_web_game_creation() -> Result<Value, String> {
|
||||
let result = host_web_creation_preflight().await;
|
||||
if result["status"] != "ready" {
|
||||
let diagnostic = result["diagnostic"]
|
||||
.as_str()
|
||||
.map(|value| format!(";诊断:{value}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Web 游戏环境预检未通过:{};尚未启动生成",
|
||||
result["code"].as_str().unwrap_or("web-preflight-failed")
|
||||
"Web 游戏环境预检未通过:{}{};尚未启动生成",
|
||||
result["code"].as_str().unwrap_or("web-preflight-failed"),
|
||||
diagnostic,
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
@@ -548,16 +609,40 @@ mod tests {
|
||||
browser_validation_failure_code("启动浏览器失败:2"),
|
||||
"web-preflight-browser-launch-failed"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-launch-failed: stage=setup cause=browser-temp-unavailable"
|
||||
),
|
||||
"web-preflight-browser-environment-failed"
|
||||
);
|
||||
|
||||
let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true";
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(recovery_diagnostic),
|
||||
"web-preflight-browser-recovery-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic(recovery_diagnostic),
|
||||
Some(recovery_diagnostic)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic("启动浏览器失败:原始错误"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
|
||||
"web-preflight-cancelled"
|
||||
);
|
||||
let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false";
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
|
||||
),
|
||||
browser_validation_failure_code(cleanup_diagnostic),
|
||||
"web-preflight-browser-cleanup-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic(cleanup_diagnostic),
|
||||
Some(cleanup_diagnostic)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
|
||||
"web-preflight-browser-environment-failed"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user