diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts
index c3a9e7256..1f7ffeec2 100644
--- a/apps/admin-web/src/api/adminApiTypes.ts
+++ b/apps/admin-web/src/api/adminApiTypes.ts
@@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
-export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
+export type AdminAgcAgentMode = 'codex' | 'cc';
+export type AdminAgcModelProtocol =
+ | 'openai_responses'
+ | 'openai_chat'
+ | 'anthropic';
export interface AdminAgcModel {
id: string;
@@ -1083,6 +1087,8 @@ export interface AdminAgcModel {
modelId: string;
enabled: boolean;
agentMode: AdminAgcAgentMode;
+ /** 上游线协议,与执行器正交。 */
+ protocol?: AdminAgcModelProtocol;
}
export interface AdminAgcModelCatalog {
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
index 77b9262bb..feb4ce9d3 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
@@ -104,3 +104,52 @@ test('new models default to Codex and save the selected Agent mode', async () =>
});
});
});
+
+test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
+ const catalog = {
+ revision: 3,
+ defaultModelId: 'quality',
+ models: [
+ {
+ id: 'quality',
+ alias: '高质量',
+ modelId: 'claude-opus-5-5',
+ enabled: true,
+ agentMode: 'cc' as const,
+ protocol: 'anthropic' as const,
+ },
+ ],
+ };
+ vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
+ vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
+ ...input,
+ revision: 4,
+ }));
+ render();
+ await screen.findByDisplayValue('claude-opus-5-5');
+
+ const agentMode = screen.getByLabelText(
+ '模型 1 Agent 模式',
+ ) as HTMLSelectElement;
+ const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
+ expect(agentMode.value).toBe('cc');
+ expect(protocol.value).toBe('anthropic');
+ // 执行器下拉里不再混入协议取值。
+ expect(Array.from(agentMode.options).map((option) => option.value)).toEqual([
+ 'codex',
+ 'cc',
+ ]);
+
+ fireEvent.change(agentMode, { target: { value: 'codex' } });
+ fireEvent.change(protocol, { target: { value: 'openai_chat' } });
+ fireEvent.click(screen.getByRole('button', { name: '保存' }));
+
+ await waitFor(() => {
+ const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
+ const payload = calls[calls.length - 1]?.[1];
+ expect(payload?.models[0]).toMatchObject({
+ agentMode: 'codex',
+ protocol: 'openai_chat',
+ });
+ });
+});
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
index cab805cf7..b009aa222 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
{ key: 'alias', label: '别名' },
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
+ { key: 'protocol', label: '协议' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
modelId: '',
enabled: true,
agentMode: 'codex',
+ protocol: 'openai_responses',
},
],
},
@@ -226,7 +228,22 @@ export function AdminAgcModelsPage({
>
-
+
+
+
+
|
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
index 263b314da..827b06682 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
@@ -313,7 +313,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result &'static str {
+ #[cfg(test)]
+ {
+ "responses"
+ }
+ #[cfg(not(test))]
+ {
+ match crate::config::load_game_creator_app_config()
+ .ok()
+ .and_then(|config| config.selected_model_protocol)
+ .as_deref()
+ {
+ Some("openai_chat") => "chat",
+ _ => "responses",
+ }
+ }
+}
+
fn configure_game_creator_codex_app_server_command_for_mode(
command: &mut tokio::process::Command,
llm: &GameCreatorLlmConfig,
@@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode(
let provider_token = provider_proxy
.map(CodexProviderProxy::downstream_bearer_token)
.unwrap_or(legacy_api_key);
+ // 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions,
+ // 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。
+ let wire_api = game_creator_codex_provider_wire_api();
command
.arg("-c")
.arg(format!(
@@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode(
.arg("-c")
.arg(format!(
"model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}",
- quoted_toml_string("responses")?
+ quoted_toml_string(wire_api)?
))
.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token);
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs
index 06b012c94..f5059ed82 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs
@@ -7338,7 +7338,7 @@ mod tests {
);
assert!(error
- .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
+ .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
assert!(error.contains(""), "{error}");
assert!(error.contains(""), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
@@ -7382,7 +7382,7 @@ mod tests {
assert!(
error.starts_with(
- "direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
+ "direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary="
),
"{error}"
);
@@ -7421,7 +7421,7 @@ mod tests {
);
assert!(
- error.contains("stage=art-preparation code=runtime-failure retryable=false"),
+ error.contains("stage=art-preparation code=runtime-unclassified retryable=false"),
"{error}"
);
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs
index 8f83bfc4d..8f1896f59 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs
@@ -204,6 +204,12 @@ fn single_line_log_field(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
+/// 直连回合失败的持久化标签。
+///
+/// 这里是**唯一**的失败分类出口,标签会被写进 `.agent/runtime/errors/*.json` 与
+/// `direct-codex-failure:v2` 文案。未识别的原因必须落回专门的 `runtime-unclassified`:
+/// 以前这里借用具体的 `runtime-failure` 兜底,任何新失败形态都会被读成「运行时失败」,
+/// 真正的失败原因反而被盖掉,也无法从标签看出分类覆盖率。
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
@@ -224,8 +230,16 @@ pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
"tool-invalid-arguments"
} else if normalized.contains("codex app-server-error:other") {
"app-server-other"
+ } else if normalized.contains("sidecar") && normalized.contains("超时") {
+ "sidecar-turn-timeout"
+ } else if normalized.contains("sidecar") && normalized.contains("启动失败") {
+ "sidecar-launch-failed"
+ } else if normalized.contains("codex app-server 启动失败") {
+ "app-server-launch-failed"
+ } else if normalized.contains("claude code 返回失败终态") {
+ "claude-code-failed"
} else {
- "runtime-failure"
+ "runtime-unclassified"
}
}
@@ -361,4 +375,39 @@ mod tests {
"tool-invalid-arguments"
);
}
+
+ #[test]
+ fn known_sidecar_and_app_server_failures_keep_their_own_codes() {
+ assert_eq!(
+ classify_direct_codex_error("Claude Agent SDK sidecar 回合超时"),
+ "sidecar-turn-timeout"
+ );
+ assert_eq!(
+ classify_direct_codex_error(
+ "Claude Agent SDK sidecar 的 Node runtime 启动失败:拒绝访问"
+ ),
+ "sidecar-launch-failed"
+ );
+ assert_eq!(
+ classify_direct_codex_error("Codex app-server 启动失败:找不到可执行文件"),
+ "app-server-launch-failed"
+ );
+ assert_eq!(
+ classify_direct_codex_error("Claude Code 返回失败终态"),
+ "claude-code-failed"
+ );
+ }
+
+ #[test]
+ fn unclassified_failures_get_a_dedicated_code() {
+ // 任何一个未识别原因都不能被读成某个具体故障:只允许专用兜底码。
+ assert_eq!(
+ classify_direct_codex_error("某种尚未识别的新失败"),
+ "runtime-unclassified"
+ );
+ assert_eq!(
+ classify_direct_codex_error("读取陶泥儿画布资源失败:"),
+ "runtime-unclassified"
+ );
+ }
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs
index 5eabb23b6..be5bc35cb 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs
@@ -13,13 +13,16 @@
use std::path::{Path, PathBuf};
+use futures::FutureExt;
+
#[cfg(test)]
use crate::agent::PendingTurn;
use crate::agent::{
append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved,
- direct_codex_user_item_to_prompt, direct_now_ms, redact_agent_runtime_error,
- run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project,
- DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn,
+ direct_codex_user_item_to_prompt, direct_now_ms, record_direct_codex_failure,
+ redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter,
+ thread_id_for_project, DirectCodexFailureStage, DirectGameCreatorTurnUpdateEmitter,
+ DirectTurnError, DirectTurnTerminal, DispatchedTurn,
};
/// 一次放行的占用。持有它就代表这一轮还没收口。
@@ -84,13 +87,90 @@ impl Drop for TurnReservation {
fn drop(&mut self) {
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
// 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。
- let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
+ let finalized_by_guard = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
+ // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。
+ // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层
+ // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与
+ // application.log 全空)。测试态不写,避免用例污染本机 AppData。
+ #[cfg(not(test))]
+ if finalized_by_guard {
+ // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场,
+ // 不必只依赖 AppData 的应用日志。
+ let failure = DirectTurnError::TurnFailed {
+ stage: DirectCodexFailureStage::CodeGeneration,
+ detail: format!(
+ "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}",
+ self.thread_id, self.token
+ ),
+ };
+ let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None);
+ let _ = crate::append_application_log(
+ "error".to_string(),
+ "direct-turn".to_string(),
+ format!(
+ "agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
+ self.thread_id,
+ self.token,
+ self.user_item_id.as_deref().unwrap_or("-"),
+ // true = 这一轮是被 panic 带走的;false = future 被丢弃或取消。
+ std::thread::panicking()
+ ),
+ );
+ }
// 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。
// panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。
kick_queue_dispatch(Path::new(&self.thread_id));
}
}
+// 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。
+// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。
+tokio::task_local! {
+ static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>;
+}
+
+/// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。
+///
+/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因"
+/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log`
+/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。
+fn ensure_direct_turn_panic_hook() {
+ static ONCE: std::sync::Once = std::sync::Once::new();
+ ONCE.call_once(|| {
+ let previous = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) {
+ let location = info
+ .location()
+ .map(|location| {
+ format!(
+ "{}:{}:{}",
+ location.file(),
+ location.line(),
+ location.column()
+ )
+ })
+ .unwrap_or_else(|| "未知位置".to_string());
+ #[cfg(not(test))]
+ let _ = crate::append_application_log(
+ "error".to_string(),
+ "direct-turn".to_string(),
+ format!(
+ "agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
+ thread_id,
+ token,
+ location,
+ info.payload_as_str().unwrap_or("未知 panic 负载")
+ ),
+ );
+ #[cfg(test)]
+ let _ = (thread_id, token, location);
+ }
+ previous(info);
+ }));
+ });
+}
+
/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。
///
/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、
@@ -109,10 +189,23 @@ pub(crate) fn kick_queue_dispatch(root: &Path) {
crate::platform_session::current_platform_session_write_state().identity_generation;
let reservation = TurnReservation::resume(&thread_id, &dispatched);
let root = root.to_path_buf();
- tauri::async_runtime::spawn(async move {
+ ensure_direct_turn_panic_hook();
+ let panic_context = Some((thread_id.clone(), dispatched.token.clone()));
+ tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move {
run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation)
.await;
- });
+ }));
+}
+
+/// 把 panic 负载转成可读文本:`panic!("…")` 的负载是 `&str`,`panic!("{x}")` 是 `String`。
+fn direct_turn_panic_detail(payload: &(dyn std::any::Any + Send)) -> String {
+ if let Some(text) = payload.downcast_ref::<&str>() {
+ (*text).to_string()
+ } else if let Some(text) = payload.downcast_ref::() {
+ text.clone()
+ } else {
+ "未知 panic 负载".to_string()
+ }
}
/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。
@@ -149,16 +242,38 @@ async fn run_dispatched_direct_turn(
crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item);
let capture = crate::analytics::gui::capture_writer_context();
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id);
- let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
- &root,
- &prompt,
- dispatched.pending.creation_type.as_deref(),
- Some(&emitter),
- Some(canonical_user_item.clone()),
- capture,
- release_identity_generation,
+ // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的
+ // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志
+ // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败,
+ // 走与正常失败同一条收口:先写脱敏诊断,再写终态。
+ let outcome = match std::panic::AssertUnwindSafe(
+ run_direct_game_creator_turn_at_with_creation_type_and_emitter(
+ &root,
+ &prompt,
+ dispatched.pending.creation_type.as_deref(),
+ Some(&emitter),
+ Some(canonical_user_item.clone()),
+ capture,
+ release_identity_generation,
+ ),
)
- .await;
+ .catch_unwind()
+ .await
+ {
+ Ok(result) => result,
+ Err(payload) => {
+ let failure = DirectTurnError::turn_failed(
+ DirectCodexFailureStage::CodeGeneration,
+ format!(
+ "DirectProject 宿主任务 panic:{}",
+ direct_turn_panic_detail(payload.as_ref())
+ ),
+ );
+ let stage = failure.turn_failure_stage();
+ let detail = record_direct_codex_failure(&root, &failure, Some(turn_id.as_str()));
+ Err(DirectTurnError::TurnFailed { stage, detail })
+ }
+ };
match outcome {
Ok(_) => {
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs
index df4619c02..1df7ccf9a 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs
@@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
}
let stylesVisible = true;
- let pointerBlocked = false;
+ // 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。
+ const pointerBlocked = getComputedStyle(this).pointerEvents === 'none';
for (let current = this; current instanceof HTMLElement; current = current.parentElement) {
const style = getComputedStyle(current);
const opacity = Number.parseFloat(style.opacity);
@@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
&& style.visibility !== 'hidden'
&& style.visibility !== 'collapse'
&& (Number.isNaN(opacity) || opacity > 0);
- pointerBlocked ||= style.pointerEvents === 'none';
}
const rect = this.getBoundingClientRect();
diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
index 0d98e7c97..9d8733862 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
@@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1);
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
- generic,
- "e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
- );
- assert_eq!(
- tetris,
- "b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
- );
- assert_eq!(
- lane,
- "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a"
+ [generic.as_str(), tetris.as_str(), lane.as_str()],
+ [
+ "85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d",
+ "60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff",
+ "a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466",
+ ]
);
assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit()));
@@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
- let result = run_stable_generic_viewport_fixture(false).await;
+ let result = run_stable_generic_viewport_fixture(false, None).await;
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
@@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
- let result = run_stable_generic_viewport_fixture(true).await;
+ let result = run_stable_generic_viewport_fixture(true, None).await;
assert!(
!result.passed,
"a successful desktop cannot hide mobile gameplay failure"
@@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
.any(|message| message.starts_with("playtest mobile:")));
}
+#[tokio::test]
+#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
+async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() {
+ let result = run_stable_generic_viewport_fixture(
+ false,
+ Some(
+ r#"
+
+ "#,
+ ),
+ )
+ .await;
+ assert!(result.passed, "{:?}", result.diagnostics);
+ for viewport in REQUIRED_VIEWPORTS {
+ let playtest = &result
+ .viewport_playtests
+ .iter()
+ .find(|entry| entry.viewport == viewport)
+ .expect("each viewport must have gameplay evidence")
+ .result;
+ assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics);
+ assert_eq!(playtest.initial_sequence, Some(0));
+ assert_eq!(playtest.final_sequence, Some(3));
+ assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
+ assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
+ }
+}
+
+#[tokio::test]
+#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
+async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() {
+ for (case, markup) in [
+ (
+ "inherited-none",
+ r#""#,
+ ),
+ (
+ "own-none",
+ r#""#,
+ ),
+ (
+ "own-none-child-auto",
+ r#""#,
+ ),
+ (
+ "disabled",
+ r#""#,
+ ),
+ (
+ "disabled-fieldset",
+ r#""#,
+ ),
+ (
+ "aria-disabled",
+ r#""#,
+ ),
+ (
+ "inert",
+ r#""#,
+ ),
+ (
+ "hidden",
+ r#""#,
+ ),
+ (
+ "covered",
+ r#""#,
+ ),
+ ] {
+ let result = run_stable_generic_viewport_fixture(false, Some(markup)).await;
+ assert!(!result.passed, "{case} must fail");
+ for viewport in REQUIRED_VIEWPORTS {
+ let playtest = &result
+ .viewport_playtests
+ .iter()
+ .find(|entry| entry.viewport == viewport)
+ .expect("each viewport must have gameplay evidence")
+ .result;
+ assert!(!playtest.passed, "{case} / {viewport:?} must fail");
+ assert!(
+ playtest
+ .diagnostics
+ .iter()
+ .any(|message| message.contains("固定试玩控件 start")),
+ "{case} / {viewport:?}: {:?}",
+ playtest.diagnostics
+ );
+ assert!(
+ playtest.assertions.iter().any(|assertion| {
+ assertion.name == "start-control-clicked" && !assertion.passed
+ }),
+ "{case} / {viewport:?} must be rejected before input"
+ );
+ if case == "own-none-child-auto" {
+ assert!(
+ playtest.diagnostics.iter().any(|message| {
+ message == "固定试玩控件 start 处于 disabled 状态"
+ }),
+ "{viewport:?}: child hit must not enable the pointer-blocked target: {:?}",
+ playtest.diagnostics
+ );
+ }
+ }
+ }
+}
+
async fn run_stable_generic_viewport_fixture(
mobile_action_broken: bool,
+ start_markup: Option<&str>,
) -> BrowserValidationResult {
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture(
|