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..612d21826 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
@@ -104,3 +104,49 @@ 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/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
index 4940fbfec..10ec599ea 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
@@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
let scope_relative = scope
.and_then(|scope| scope.project_relative)
.filter(|path| !path.is_empty());
- let listed = list_local_project_files_at(root)?;
- let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/"));
+ let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| {
+ bridge_project_file_is_hidden_control_path(path)
+ || should_skip_project_snapshot_path(path)
+ || reject_sensitive_project_file_read(path).is_err()
+ })?;
let mut files = listed
.files
.into_iter()
.filter(|file| file.kind == "file")
- .filter(|file| !bridge_project_file_is_hidden_control_path(&file.path))
- .filter(|file| !should_skip_project_snapshot_path(&file.path))
- .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok())
- .filter(|file| {
- scope_relative.as_ref().is_none_or(|scope| {
- file.path == *scope
- || scope_prefix
- .as_ref()
- .is_some_and(|prefix| file.path.starts_with(prefix))
- })
- })
.filter(|file| {
let (category, _) = bridge_project_file_class(&file.path);
requested_kind == "all" || requested_kind == category
@@ -1622,11 +1614,13 @@ async fn bridge_import_account_assets(
}
}
if !local_paths.is_empty() {
- match crate::commands::import_local_project_assets_for_agent_with_write_permit(
+ match crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
&state.root,
&local_paths,
write_permit.as_ref(),
- ) {
+ )
+ .await
+ {
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
json!({
"id": asset.id,
@@ -4193,6 +4187,73 @@ mod tests {
);
}
+ #[test]
+ fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() {
+ let temporary = tempfile::tempdir().unwrap();
+ let root = temporary.path();
+ init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap();
+ for path in [
+ "assets/z.png",
+ "assets/nested/b.png",
+ "assets/a.png",
+ "assets/notes.txt",
+ "assets-other/wrong.png",
+ "unrelated/wrong.png",
+ ".agent/hidden.png",
+ "assets/.env",
+ "assets/secret.key",
+ "assets/.codex/hidden.png",
+ "assets/node_modules/hidden.png",
+ "assets/.git/hidden.png",
+ ] {
+ let target = root.join(path);
+ fs::create_dir_all(target.parent().unwrap()).unwrap();
+ fs::write(target, b"test-only").unwrap();
+ }
+ let payload = |arguments: Value| {
+ let result = bridge_list_project_files(root, &arguments);
+ assert_eq!(result["isError"], false, "{result}");
+ serde_json::from_str::(result["content"][0]["text"].as_str().unwrap()).unwrap()
+ };
+ let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 }));
+ assert_eq!(first["total"], 3);
+ assert_eq!(first["offset"], 0);
+ assert_eq!(first["limit"], 2);
+ assert_eq!(first["nextOffset"], 2);
+ assert_eq!(first["files"][0]["path"], "assets/a.png");
+ assert_eq!(first["files"][1]["path"], "assets/nested/b.png");
+ let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 }));
+ assert_eq!(last["total"], 3);
+ assert_eq!(last["files"].as_array().unwrap().len(), 1);
+ assert_eq!(last["files"][0]["path"], "assets/z.png");
+ assert!(last["nextOffset"].is_null());
+ for path in ["assets/nested", "assets/nested/b.png"] {
+ let page = payload(json!({ "path": path }));
+ assert_eq!(page["total"], 1);
+ assert_eq!(page["files"][0]["path"], "assets/nested/b.png");
+ }
+ assert_eq!(
+ payload(json!({ "path": "assets", "query": "NESTED" }))["total"],
+ 1
+ );
+ assert_eq!(payload(json!({ "path": "assets" }))["total"], 4);
+ assert_eq!(payload(json!({ "path": "missing" }))["total"], 0);
+ let beyond = payload(json!({ "path": "assets", "offset": 20 }));
+ assert!(beyond["files"].as_array().unwrap().is_empty());
+ assert!(beyond["nextOffset"].is_null());
+ for path in [
+ ".agent",
+ "assets/.env",
+ "assets/.codex",
+ "assets/node_modules",
+ ] {
+ assert_eq!(
+ bridge_list_project_files(root, &json!({ "path": path }))["isError"],
+ true
+ );
+ }
+ }
+
#[tokio::test]
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
use crate::analytics::{
@@ -4748,21 +4809,39 @@ mod tests {
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
- session
- .interrupt("fixture cancellation before asset commit".into())
- .unwrap();
- assert!(
- crate::commands::import_local_project_assets_for_agent_with_write_permit(
+ assert!(crate::tests::with_conversation_write_contention(
+ root,
+ crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
root,
&["game/import-source.png".to_string()],
Some(&permit),
- )
- .is_err()
- );
- assert!(
- crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit)
- .is_err()
- );
+ ),
+ || session
+ .interrupt("fixture cancellation during asset lock wait".into())
+ .unwrap(),
+ )
+ .await
+ .is_err());
+ let commit_root = root.to_path_buf();
+ let commit_bytes = bytes.clone();
+ let commit_permit = permit.clone();
+ assert!(crate::tests::with_conversation_write_contention(
+ root,
+ async move {
+ tokio::task::spawn_blocking(move || {
+ crate::commands::commit_account_asset_for_write_permit_test(
+ &commit_root,
+ &commit_bytes,
+ &commit_permit,
+ )
+ })
+ .await
+ .unwrap()
+ },
+ || {}
+ )
+ .await
+ .is_err());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).unwrap(),
manifest_before
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/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs
index f888c3f61..9ad7bbdd8 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs
@@ -583,7 +583,8 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import(
}
}
if !local_paths.is_empty() {
- match import_local_project_assets_for_agent(root, &local_paths) {
+ match import_local_project_assets_for_agent_in_blocking_pool(root, &local_paths, None).await
+ {
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
serde_json::json!({
"id": asset.id,
@@ -709,6 +710,35 @@ pub(in crate::agent) fn observe_agent_runtime_project_search(
mod asset_import_input_tests {
use super::*;
+ #[tokio::test]
+ async fn runtime_local_import_waits_without_blocking_worker() {
+ let project = tempfile::tempdir().unwrap();
+ let root = project.path();
+ init_local_game_project_at(root, "runtime-import-wait", "Runtime 导入等待").unwrap();
+ fs::write(root.join("game/import.md"), b"# Imported").unwrap();
+ let observation = crate::tests::with_conversation_write_contention(
+ root,
+ async {
+ let observation = observe_agent_runtime_asset_import(
+ root,
+ &serde_json::json!({"localPaths": ["game/import.md"]}),
+ )
+ .await;
+ if observation.status != "ok" {
+ return Err(format!("{observation:?}"));
+ }
+ Ok(observation)
+ },
+ || {},
+ )
+ .await
+ .unwrap();
+ let detail: serde_json::Value =
+ serde_json::from_str(observation.detail.as_deref().unwrap()).unwrap();
+ assert_eq!(detail["imported"].as_array().unwrap().len(), 1);
+ assert_eq!(detail["revisionAdvanceCount"], 1);
+ }
+
#[test]
fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() {
let temporary = tempfile::tempdir().expect("create asset context project root");
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(
|