From 9fe800da800a7be17ddb5e23c273b25e2fe5e4a8 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:24:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=20Native=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=AE=A4=E8=AF=81=E4=B8=8E=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为画布与资源编辑 loopback fixture 增加 task-local External Editor 凭据 - 为平台账号语义测试安装隔离会话并修正资源编辑错误分类断言 - 为 Windows mock listener 增加有界 accept 与 blocking stream 恢复 - 同步 autonomous main-loop、completion fixture 与项目决策记录 --- .../src/agent/generation/canvas_generation.rs | 279 +++++++---- .../agent/runtime_driver/main_loop_tests.rs | 30 +- .../autonomous_completion_contract_tests.rs | 12 +- .../src-tauri/src/assets.rs | 29 +- .../src/project/asset_canvas/generation.rs | 443 +++++++++++------- .../src-tauri/src/project/resource_editor.rs | 279 +++++++---- .../shared-memory/decision-log.md | 8 + 7 files changed, 683 insertions(+), 397 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 900c97c42..0a1c36e55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -6165,13 +6165,22 @@ mod canvas_generation_tests { })).collect::>() }); - let slices = prepare_platform_art_spritesheet_slices( - &reqwest::Client::new(), - &base_url, - "test-api-key", - &generated, - 0, - 0, + let slices = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "test-api-key".to_string(), + ), + async { + prepare_platform_art_spritesheet_slices( + &reqwest::Client::new(), + &base_url, + "test-api-key", + &generated, + 0, + 0, + ) + .await + }, ) .await .expect("prepare external icon slices"); @@ -6367,13 +6376,29 @@ mod canvas_generation_tests { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery fixture"); + listener + .set_nonblocking(true) + .expect("set prepared recovery fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let server_base_url = base_url.clone(); let png = rgba_test_png(u8::MAX).bytes; let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { for request_index in 0..4 { - let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for prepared recovery request {request_index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept prepared recovery request: {error}"), + } + }; let request = read_test_http_request(&mut stream); request_sender .send(request) @@ -6434,15 +6459,6 @@ mod canvas_generation_tests { } } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": base_url, - "apiKey": "prepared-recovery-key" - } - }) - .to_string(), - ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -6480,27 +6496,36 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build first submit client"); - let first_error = submit_external_generation_request( - &first_submit_client, - &base_url, - "/api/external/v1/editor/images/generations", - "prepared-recovery-key", - &stable_key, - &stable_body, + let (first_error, prepared) = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "prepared-recovery-key".to_string(), + ), + async { + let first_error = submit_external_generation_request( + &first_submit_client, + &base_url, + "/api/external/v1/editor/images/generations", + "prepared-recovery-key", + &stable_key, + &stable_body, + ) + .await + .expect_err("first response is intentionally lost"); + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + "恢复时不得重建这个提示词", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + .expect("resume prepared generation with the durable request"); + (first_error, prepared) + }, ) - .await - .expect_err("first response is intentionally lost"); + .await; assert!(platform_art_generation_error_result_unknown(&first_error)); - - let prepared = request_platform_art_asset_with_runtime_options_at( - root, - "恢复时不得重建这个提示词", - &[], - &PlatformArtAssetGenerationOptions::default(), - Some(&runtime_context), - ) - .await - .expect("resume prepared generation with the durable request"); server.join().expect("join prepared recovery fixture"); assert_eq!( prepared.canvas_context.project_id, @@ -6595,14 +6620,23 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build prepared auth client"); - let error = resume_prepared_external_generation_at( - root, - &client, - &client, - &base_url, - "prepared-auth-key", - "/api/external/v1/editor/images/generations", - state, + let error = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "prepared-auth-key".to_string(), + ), + async { + resume_prepared_external_generation_at( + root, + &client, + &client, + &base_url, + "prepared-auth-key", + "/api/external/v1/editor/images/generations", + state, + ) + .await + }, ) .await .expect_err("auth rejection cannot prove the original request was not accepted"); @@ -6678,17 +6712,26 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build polling client"); - let result = wait_for_external_generation_result( - &client, - &base_url, - "test-api-key", - &serde_json::json!({ - "data": { - "operationId": "task-async", - "status": "queued", - "pollAfterMs": 0 - } - }), + let result = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "test-api-key".to_string(), + ), + async { + wait_for_external_generation_result( + &client, + &base_url, + "test-api-key", + &serde_json::json!({ + "data": { + "operationId": "task-async", + "status": "queued", + "pollAfterMs": 0 + } + }), + ) + .await + }, ) .await .expect("poll completed result"); @@ -6923,15 +6966,6 @@ mod canvas_generation_tests { .expect("set configuration drift fixture nonblocking"); let current_base_url = format!("http://{}", listener.local_addr().expect("fixture address")); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": current_base_url, - "apiKey": "current-editor-key" - } - }) - .to_string(), - ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -6962,18 +6996,27 @@ mod canvas_generation_tests { mark_platform_art_generation_runtime_accepted(root, state, "stale-operation", 0) .expect("mark stale operation accepted"); - let error = match request_platform_art_asset_with_runtime_options_at( - root, - "不得覆盖旧请求", - &[], - &PlatformArtAssetGenerationOptions::default(), - Some(&runtime_context), + let error = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + current_base_url, + "current-editor-key".to_string(), + ), + async { + match request_platform_art_asset_with_runtime_options_at( + root, + "不得覆盖旧请求", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("configuration drift must block GET-only recovery"), + } + }, ) - .await - { - Err(error) => error, - Ok(_) => panic!("configuration drift must block GET-only recovery"), - }; + .await; assert!(error.contains("服务地址身份"), "{error}"); assert!(matches!( listener.accept(), @@ -7003,10 +7046,26 @@ mod canvas_generation_tests { .expect("allow generation recovery"); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted failure fixture"); + listener + .set_nonblocking(true) + .expect("set accepted failure fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept failed operation GET"); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for failed operation GET" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept failed operation GET: {error}"), + } + }; let request = read_test_http_request(&mut stream); request_sender .send(request) @@ -7028,12 +7087,6 @@ mod canvas_generation_tests { .write_all(response.as_bytes()) .expect("write failed operation response"); }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { "baseUrl": base_url.clone(), "apiKey": "accepted-failure-key" } - }) - .to_string(), - ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -7066,18 +7119,27 @@ mod canvas_generation_tests { mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0) .expect("mark failed operation accepted"); - let error = match request_platform_art_asset_with_runtime_options_at( - root, - "不得重新提交", - &[], - &PlatformArtAssetGenerationOptions::default(), - Some(&runtime_context), + let error = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url, + "accepted-failure-key".to_string(), + ), + async { + match request_platform_art_asset_with_runtime_options_at( + root, + "不得重新提交", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("explicit operation failure must be returned"), + } + }, ) - .await - { - Err(error) => error, - Ok(_) => panic!("explicit operation failure must be returned"), - }; + .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); assert!(request_receiver @@ -7133,7 +7195,7 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture accepted scan request"); - if request.starts_with("GET /api/external/v1/generations/test-operation-id ") { + if request.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id ") { let body = serde_json::json!({ "data": { "operationId": "test-operation-id", @@ -7178,7 +7240,6 @@ mod canvas_generation_tests { ]); let _config_guard = crate::tests::write_test_local_config( serde_json::json!({ - "editorApi": { "baseUrl": base_url, "apiKey": "recovery-editor-key" }, "agentLlm": { "art-director": { "apiKey": "recovery-llm-key", @@ -7190,6 +7251,11 @@ mod canvas_generation_tests { }) .to_string(), ); + let _platform_session = crate::platform_session::install_test_platform_session( + "accepted-recovery-scan-user", + "recovery-editor-key", + &base_url, + ); let run_id = "accepted-recovery-scan-run"; setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id) .expect("setup accepted generation recovery state"); @@ -7217,7 +7283,7 @@ mod canvas_generation_tests { stop_sender.send(()).expect("stop accepted scan fixture"); server.join().expect("join accepted scan fixture"); - assert!(first.starts_with("GET /api/external/v1/generations/test-operation-id ")); + assert!(first.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id ")); assert!(second.starts_with("GET /artifact.png ")); assert!(request_receiver .recv_timeout(Duration::from_millis(100)) @@ -7287,11 +7353,24 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build legacy download client"); - let download = - resolve_canvas_resource_download(&client, &base_url, "test-api-key", &download_source) + let download = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "test-api-key".to_string(), + ), + async { + resolve_canvas_resource_download( + &client, + &base_url, + "test-api-key", + &download_source, + ) .await - .expect("resolve legacy spritesheet download") - .expect("legacy spritesheet download"); + }, + ) + .await + .expect("resolve legacy spritesheet download") + .expect("legacy spritesheet download"); assert_eq!(download.bytes, expected_bytes); assert_eq!(download.media_type, "image/png"); let read_url_request = receiver diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index cc12e1f9b..dddbc5789 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -2291,8 +2291,10 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf .expect_err("art-director without External Editor API must remain read-only"); assert!(error.contains("自主构建只读专业 Agent 禁止执行写入或副作用动作")); } - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-ready-task-user", + "visual-ready-task-test-key", + "https://dev.genarrative.world", ); assert!(autonomous_manifest_ready_task_requires_visual_asset( "art-director" @@ -2322,8 +2324,10 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf #[test] fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planning() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"game-chat-fast-art-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "game-chat-fast-art-user", + "game-chat-fast-art-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create game-chat art root"); let root = temporary.path().join("project"); @@ -2367,8 +2371,10 @@ fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planni #[test] fn game_chat_art_asset_plan_uses_deterministic_icon_spritesheet_generation() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"game-chat-fast-spritesheet-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "game-chat-fast-spritesheet-user", + "game-chat-fast-spritesheet-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create game-chat spritesheet root"); let root = temporary.path().join("project"); @@ -4503,8 +4509,10 @@ fn game_chat_scheduler_recovers_deterministic_active_code_prototype_with_v1_task const PARENT_RUN_ID: &str = "game-chat-v1-ready-text-recovery-parent"; const PARENT_TASK: &str = "继续把已有美术接入俄罗斯方块"; const MAIN_AGENT_ID: &str = "code-prototype"; - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"game-chat-v1-task-recovery-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "game-chat-v1-task-recovery-user", + "game-chat-v1-task-recovery-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create v1 task recovery root"); let root = temporary.path().join("project"); @@ -4995,8 +5003,10 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent"; const PARENT_TASK: &str = "生成完整小游戏并恢复丢失的正式视觉产物"; const CHILD_ID: &str = "art-director"; - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-recovery-test-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-recovery-user", + "visual-recovery-test-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create visual recovery root"); let root = temporary.path().join("project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index ebdae7dbd..374c8e7f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -6406,8 +6406,10 @@ fn canvas_visual_gate_rejects_dimensions_from_a_shadow_canvas_object() { #[test] fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "cli-art-gate-user", + "cli-art-gate-key", + "https://dev.genarrative.world", ); let (_temporary, root, parent_state, _contract) = autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent"); @@ -6480,8 +6482,10 @@ fn cli_code_prototype_accepts_linked_inline_and_external_modules_for_canvas_atla #[test] fn cli_code_prototype_rejects_unlinked_unreachable_modules_and_html_path_decoys() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"cli-module-decoy-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "cli-module-decoy-user", + "cli-module-decoy-key", + "https://dev.genarrative.world", ); let (_temporary, root, parent_state, _contract) = autonomous_fixture("创建完整小游戏", "cli-module-decoy-parent"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index fa776c2eb..a7097a6ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,6 +1,6 @@ use super::*; -use std::future::Future; use sha2::{Digest as _, Sha256}; +use std::future::Future; const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-"; const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024; @@ -47,6 +47,17 @@ pub(crate) async fn with_external_editor_api_credentials( .await } +#[cfg(test)] +pub(crate) fn external_editor_api_credentials_for_test( + api_base_url: String, + api_key: String, +) -> ExternalEditorApiCredentials { + ExternalEditorApiCredentials { + api_base_url, + api_key, + } +} + fn private_external_editor_api_key_directory() -> Result { let home = std::env::var_os("USERPROFILE") .or_else(|| std::env::var_os("HOME")) @@ -56,7 +67,9 @@ fn private_external_editor_api_key_directory() -> Result { Ok(home.join(".config").join("genarrative")) } -fn private_external_editor_api_key_path_for_base_url(api_base_url: &str) -> Result { +fn private_external_editor_api_key_path_for_base_url( + api_base_url: &str, +) -> Result { let api_base_url = normalize_external_editor_api_base_url(api_base_url)?; let fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes())); Ok(private_external_editor_api_key_directory()?.join(format!( @@ -1675,13 +1688,11 @@ mod tests { fn private_external_editor_credentials_are_isolated_by_server_origin() { let local = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8082") .expect("local credential path"); - let previous = - private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8085") - .expect("previous local credential path"); - let dev = private_external_editor_api_key_path_for_base_url( - "https://dev.genarrative.world", - ) - .expect("dev credential path"); + let previous = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8085") + .expect("previous local credential path"); + let dev = + private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world") + .expect("dev credential path"); assert_ne!(local, previous); assert_ne!(local, dev); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 4a882ae7d..b499934ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -2657,10 +2657,26 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( mod tests { use super::*; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + use std::future::Future; use std::io::{Cursor, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; + async fn with_test_external_editor_credentials( + api_base_url: &str, + api_key: &str, + operation: impl Future, + ) -> T { + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + api_base_url.to_string(), + api_key.to_string(), + ), + operation, + ) + .await + } + fn test_png_with_color(color: [u8; 4]) -> Vec { let image = RgbaImage::from_pixel(4, 3, Rgba(color)); let mut output = Cursor::new(Vec::new()); @@ -2707,6 +2723,32 @@ mod tests { String::from_utf8_lossy(&bytes).into_owned() } + fn accept_generation_fixture_connection( + listener: &TcpListener, + fixture: &str, + request_index: usize, + ) -> TcpStream { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore generation fixture stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {fixture} request {request_index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept {fixture} request {request_index}: {error}"), + } + } + } + fn write_json(stream: &mut TcpStream, status: &str, body: serde_json::Value) { let body = body.to_string(); write!( @@ -2917,8 +2959,15 @@ mod tests { let png = test_png(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..6 { - let (mut stream, _) = listener.accept().expect("accept generation request"); + listener + .set_nonblocking(true) + .expect("set generation fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "generation fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -2985,16 +3034,15 @@ mod tests { } }); let api_key = "phase-five-secret-api-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), - ); let private_prompt = "只允许进入私有账本的完整提示词正文"; let input = generation_input(directory.path(), project_id, &draft, private_prompt); let mut progress = Vec::new(); - let first = + let first = with_test_external_editor_credentials(&base_url, api_key, async { generate_asset_canvas_image_at(directory.path(), &input, |event| progress.push(event)) .await - .expect("generate and commit image"); + }) + .await + .expect("generate and commit image"); server.join().expect("join generation server"); assert_eq!( @@ -3052,9 +3100,11 @@ mod tests { .to_ascii_lowercase() .contains(&format!("idempotency-key: {}", input.idempotency_key))); - let replay = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) - .await - .expect("replay committed generation"); + let replay = with_test_external_editor_credentials(&base_url, api_key, async { + generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("replay committed generation"); assert_eq!(replay.result.commit, first.result.commit); assert!(replay.event.is_none()); @@ -3062,10 +3112,12 @@ mod tests { generation_input(directory.path(), project_id, &draft, private_prompt); conflicting.intent_id = input.intent_id.clone(); conflicting.idempotency_key = input.idempotency_key.clone(); - let conflict = generate_asset_canvas_image_at(directory.path(), &conflicting, |_| {}) - .await - .err() - .expect("reject identity reuse under another generation"); + let conflict = with_test_external_editor_credentials(&base_url, api_key, async { + generate_asset_canvas_image_at(directory.path(), &conflicting, |_| {}).await + }) + .await + .err() + .expect("reject identity reuse under another generation"); assert!(conflict.contains("不同 generationId")); let public_draft = read_asset_canvas_draft_at( @@ -3136,6 +3188,10 @@ mod tests { let upload_url = format!("{base_url}/oss-upload"); let signed_url = format!("{base_url}/refined.png"); let result_png = test_png_with_color([218, 42, 64, 255]); + let source_image = + image::load_from_memory(&source_png).expect("decode refine source image"); + let source_width = source_image.width(); + let source_height = source_image.height(); let mut input = generation_input( directory.path(), project_id, @@ -3154,8 +3210,15 @@ mod tests { let source_png_len = source_png.len(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..10 { - let (mut stream, _) = listener.accept().expect("accept refine generation request"); + listener + .set_nonblocking(true) + .expect("set refine generation fixture nonblocking"); + for request_index in 0..10 { + let mut stream = accept_generation_fixture_connection( + &listener, + "refine generation fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3259,15 +3322,10 @@ mod tests { } } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "refine-private-api-key" - }}) - .to_string(), - ); - - let execution = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + let execution = + with_test_external_editor_credentials(&base_url, "refine-private-api-key", async { + generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await + }) .await .expect("upload local source and complete refine generation"); server.join().expect("join refine generation server"); @@ -3311,8 +3369,8 @@ mod tests { assert!(requests[confirm_index].contains(&expected_object_key)); assert!(requests[register_index] .contains(&format!("\"imageSrc\":\"/{}\"", expected_object_key))); - assert!(requests[register_index].contains("\"width\":2")); - assert!(requests[register_index].contains("\"height\":2")); + assert!(requests[register_index].contains(&format!("\"width\":{source_width}"))); + assert!(requests[register_index].contains(&format!("\"height\":{source_height}"))); assert!(requests[submit_index].contains("\"sourceReferenceId\":\"editor-resource-source\"")); assert!(!requests[submit_index].contains("sourceImageSrc")); assert!(!requests[submit_index].contains("sourceResourceId")); @@ -3347,7 +3405,10 @@ mod tests { .expect("read refine ledger") .expect("refine ledger exists"); assert_eq!(ledger.operation_id.as_deref(), Some("refine-operation")); - assert_eq!(ledger.resolved_reference_ids, vec![expected_object_key]); + assert_eq!( + ledger.resolved_reference_ids, + vec!["editor-resource-source".to_string()] + ); assert!(ledger.reference_states.iter().all(|state| { state.upload_bucket.is_none() && state.upload_object_key.is_none() @@ -3367,8 +3428,15 @@ mod tests { ); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..3 { - let (mut stream, _) = listener.accept().expect("accept ticket failure request"); + listener + .set_nonblocking(true) + .expect("set ticket failure fixture nonblocking"); + for request_index in 0..3 { + let mut stream = accept_generation_fixture_connection( + &listener, + "ticket failure fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3403,13 +3471,6 @@ mod tests { } } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "ticket-failure-private-api-key" - }}) - .to_string(), - ); let input = generation_input( directory.path(), project_id, @@ -3417,10 +3478,14 @@ mod tests { "这段提示词不得进入公开错误", ); - let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) - .await - .err() - .expect("ticket failure must stop generation"); + let error = with_test_external_editor_credentials( + &base_url, + "ticket-failure-private-api-key", + async { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await }, + ) + .await + .err() + .expect("ticket failure must stop generation"); server.join().expect("join ticket failure server"); assert_eq!(error, "参考资源上传凭证申请失败,未提交生成"); @@ -3496,8 +3561,15 @@ mod tests { let png = test_png(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..3 { - let (mut stream, _) = listener.accept().expect("accept recovery request"); + listener + .set_nonblocking(true) + .expect("set accepted recovery fixture nonblocking"); + for request_index in 0..3 { + let mut stream = accept_generation_fixture_connection( + &listener, + "accepted recovery fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3534,9 +3606,6 @@ mod tests { }); let old_api_key = "phase-five-recovery-old-key"; let api_key = "phase-five-recovery-rotated-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), - ); let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); ledger.api_identity_scheme = None; ledger.api_identity_fingerprint = Some( @@ -3554,9 +3623,11 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; - let blocked = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) - .await - .expect("request explicit service identity confirmation"); + let blocked = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("request explicit service identity confirmation"); assert!(blocked.result.resumed_generation_ids.is_empty()); assert_eq!(blocked.result.service_identity_confirmations.len(), 1); assert!(receiver.try_recv().is_err(), "确认前不得访问网络"); @@ -3566,17 +3637,20 @@ mod tests { assert_eq!(confirmation.operation_state, "accepted"); assert_eq!(confirmation.service_origin, base_url); - let confirmed = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: generation_id.clone(), - operation_id: operation_id.clone(), - challenge: confirmation.challenge, - }, - ) + let confirmed = with_test_external_editor_credentials(&base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + generation_id: generation_id.clone(), + operation_id: operation_id.clone(), + challenge: confirmation.challenge, + }, + ) + .await + }) .await .expect("confirm the current service for the legacy operation"); assert_eq!( @@ -3584,9 +3658,11 @@ mod tests { PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME ); - let first = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) - .await - .expect("recover accepted generation after confirmation"); + let first = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("recover accepted generation after confirmation"); server.join().expect("join recovery server"); assert_eq!(first.result.resumed_generation_ids, vec![generation_id]); assert!(first.result.service_identity_confirmations.is_empty()); @@ -3595,9 +3671,11 @@ mod tests { assert_eq!(requests.len(), 3); assert!(requests.iter().all(|request| request.starts_with("GET "))); - let second = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) - .await - .expect("ignore already committed generation"); + let second = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("ignore already committed generation"); assert!(second.result.resumed_generation_ids.is_empty()); assert!(second.result.service_identity_confirmations.is_empty()); assert!(second.events.is_empty()); @@ -3611,10 +3689,7 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "服务身份陈旧挑战测试"); let base_url = "https://editor.example.test"; let old_api_key = "old-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": "rotated-key"}}) - .to_string(), - ); + let api_key = "rotated-key"; let mut ledger = accepted_ledger(project_id, &draft, base_url, old_api_key); ledger.api_identity_scheme = None; ledger.api_identity_fingerprint = Some( @@ -3631,9 +3706,11 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; - let first = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("issue first challenge"); + let first = with_test_external_editor_credentials(base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("issue first challenge"); let first_confirmation = first.result.service_identity_confirmations[0].clone(); let mut expired = read_generation_ledger(directory.path(), &generation_id) .expect("read ledger") @@ -3644,41 +3721,49 @@ mod tests { .expect("confirmation exists") .expires_at = asset_canvas_now(); write_generation_ledger(directory.path(), &mut expired).expect("expire challenge"); - let expired_error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: generation_id.clone(), - operation_id: operation_id.clone(), - challenge: first_confirmation.challenge, - }, - ) + let expired_error = with_test_external_editor_credentials(base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + generation_id: generation_id.clone(), + operation_id: operation_id.clone(), + challenge: first_confirmation.challenge, + }, + ) + .await + }) .await .expect_err("expired challenge must fail closed"); assert!(expired_error.contains("过期或上下文已变化")); - let second = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("rotate expired challenge"); + let second = with_test_external_editor_credentials(base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("rotate expired challenge"); let second_confirmation = second.result.service_identity_confirmations[0].clone(); let mut changed = read_generation_ledger(directory.path(), &generation_id) .expect("read ledger") .expect("ledger exists"); changed.phase = GenerationLedgerPhase::Running; write_generation_ledger(directory.path(), &mut changed).expect("change operation state"); - let stale_error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id, - operation_id, - challenge: second_confirmation.challenge, - }, - ) + let stale_error = with_test_external_editor_credentials(base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + generation_id, + operation_id, + challenge: second_confirmation.challenge, + }, + ) + .await + }) .await .expect_err("ledger changes must invalidate the challenge"); assert!(stale_error.contains("过期或上下文已变化")); @@ -3690,13 +3775,7 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "服务身份地址变化测试"); let original_base_url = "https://editor.example.test"; let old_api_key = "old-key"; - let config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": original_base_url, - "apiKey": "rotated-key" - }}) - .to_string(), - ); + let original_api_key = "rotated-key"; let mut ledger = accepted_ledger(project_id, &draft, original_base_url, old_api_key); ledger.api_identity_scheme = None; ledger.api_identity_fingerprint = Some( @@ -3708,35 +3787,38 @@ mod tests { let generation_id = ledger.generation_id.clone(); let operation_id = ledger.operation_id.clone(); write_generation_ledger(directory.path(), &mut ledger).expect("write legacy ledger"); - let blocked = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("issue service challenge"); + let blocked = + with_test_external_editor_credentials(original_base_url, original_api_key, async { + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + }) + .await + .expect("issue service challenge"); let confirmation = blocked.result.service_identity_confirmations[0].clone(); - drop(config_guard); - let _changed_config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": "https://replacement-editor.example.test", - "apiKey": "replacement-key" - }}) - .to_string(), - ); - let error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id, - operation_id, - challenge: confirmation.challenge, + let error = with_test_external_editor_credentials( + "https://replacement-editor.example.test", + "replacement-key", + async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + generation_id, + operation_id, + challenge: confirmation.challenge, + }, + ) + .await }, ) .await @@ -3759,8 +3841,15 @@ mod tests { let png = test_png(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..4 { - let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + listener + .set_nonblocking(true) + .expect("set prepared recovery fixture nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "prepared recovery fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3806,13 +3895,7 @@ mod tests { } }); let old_api_key = "prepared-old-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "prepared-rotated-key" - }}) - .to_string(), - ); + let api_key = "prepared-rotated-key"; let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); ledger.phase = GenerationLedgerPhase::Prepared; ledger.operation_id = None; @@ -3836,28 +3919,34 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; - let blocked = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("request prepared service confirmation"); + let blocked = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("request prepared service confirmation"); assert!(receiver.try_recv().is_err(), "确认前不得提交 prepared 请求"); let confirmation = blocked.result.service_identity_confirmations[0].clone(); - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id: generation_id.clone(), - operation_id: None, - challenge: confirmation.challenge, - }, - ) + with_test_external_editor_credentials(&base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + generation_id: generation_id.clone(), + operation_id: None, + challenge: confirmation.challenge, + }, + ) + .await + }) .await .expect("confirm prepared service identity"); - let recovered = - recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("replay prepared request"); + let recovered = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("replay prepared request"); server.join().expect("join prepared recovery server"); assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); @@ -4126,20 +4215,11 @@ mod tests { )); } - #[test] - fn external_api_key_is_private_and_canvas_routes_are_external_only() { + #[tokio::test] + async fn external_api_key_is_private_and_canvas_routes_are_external_only() { let project_id = "phase-five-external-key"; let (directory, draft) = create_generation_fixture(project_id, "阶段五 External 凭据测试"); let secret = "developer-api-key-must-never-be-persisted"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": "http://127.0.0.1:9", - "apiKey": secret - } - }) - .to_string(), - ); let input = generation_input( directory.path(), project_id, @@ -4147,10 +4227,15 @@ mod tests { "External 凭据不能进入私有账本", ); - let mut ledger = - validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); - let (api_base_url, api_mode) = - resolve_generation_api_mode().expect("resolve external mode"); + let (mut ledger, api_base_url, api_mode) = + with_test_external_editor_credentials("http://127.0.0.1:9", secret, async { + let ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); + let (api_base_url, api_mode) = + resolve_generation_api_mode().expect("resolve external mode"); + (ledger, api_base_url, api_mode) + }) + .await; assert_eq!(api_mode.bearer_token(), secret); ledger.canvas_context = Some(PrivateCanvasContext { project_id: "external-project".to_string(), @@ -4208,20 +4293,16 @@ mod tests { } #[tokio::test] - async fn missing_configuration_fails_explicitly_without_creating_an_asset() { + async fn missing_platform_login_fails_explicitly_without_creating_an_asset() { let project_id = "phase-five-missing-config"; let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": "http://127.0.0.1:9", "apiKey": ""}}) - .to_string(), - ); let prompt = "配置缺失时也不能公开的正文"; let input = generation_input(directory.path(), project_id, &draft, prompt); let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) .await .err() .expect("missing configuration must fail"); - assert!(error.contains("配置缺失")); + assert!(error.contains("陶泥儿登录态缺失")); assert!(!error.contains(prompt)); assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index b33846dea..91972015a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -4208,12 +4208,57 @@ pub(crate) async fn derive_local_project_resource_at( #[cfg(test)] mod tests { use super::*; + use std::future::Future; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; const PROJECT_ID: &str = "resource-editor-test-project"; + async fn with_test_external_editor_credentials( + api_base_url: &str, + api_key: &str, + operation: impl Future, + ) -> T { + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + api_base_url.to_string(), + api_key.to_string(), + ), + operation, + ) + .await + } + + fn accept_resource_editor_fixture_connection( + listener: &TcpListener, + fixture: &str, + request_index: usize, + ) -> TcpStream { + listener + .set_nonblocking(true) + .expect("set resource editor fixture listener nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore resource editor fixture stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {fixture} request {request_index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept {fixture} request {request_index}: {error}"), + } + } + } + fn read_http_request(stream: &mut TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(2))) @@ -4693,16 +4738,24 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth fixture"); let base_url = format!("http://{}", listener.local_addr().expect("auth address")); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept auth request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "auth fixture", 0); let _ = read_http_request(&mut stream); write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); - let error = + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { submit_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await - .expect_err("authentication status must fail"); + }) + .await + .expect_err("authentication status must fail"); server.join().expect("join auth fixture"); - assert!(error.contains("authentication-required")); + let expected_code = if status.starts_with("401") { + "authentication-required" + } else { + "permission-denied" + }; + assert!(error.contains(expected_code), "{status}: {error}"); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Prepared, "{status}"); assert!(ledger.remote_operation_id.is_none(), "{status}"); } @@ -4717,17 +4770,25 @@ mod tests { listener.local_addr().expect("poll auth address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept poll auth request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "poll auth fixture", 0); let request = read_http_request(&mut stream); assert!(request.starts_with("GET /api/external/v1/generations/remote-one ")); write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); - let error = + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await - .expect_err("poll authentication status must fail"); + }) + .await + .expect_err("poll authentication status must fail"); server.join().expect("join poll auth fixture"); - assert!(error.contains("authentication-required")); + let expected_code = if status.starts_with("401") { + "authentication-required" + } else { + "permission-denied" + }; + assert!(error.contains(expected_code), "{status}: {error}"); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted, "{status}"); assert_eq!(ledger.remote_operation_id.as_deref(), Some("remote-one")); } @@ -4738,7 +4799,8 @@ mod tests { listener.local_addr().expect("missing status address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept status request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "missing status fixture", 0); let _ = read_http_request(&mut stream); write_json( &mut stream, @@ -4746,10 +4808,12 @@ mod tests { serde_json::json!({"error": "missing"}), ); }); - let error = + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await - .expect_err("missing remote status must stay unknown"); + }) + .await + .expect_err("missing remote status must stay unknown"); server.join().expect("join missing status fixture"); assert!(error.contains("result-unknown")); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); @@ -4780,13 +4844,6 @@ mod tests { listener.local_addr().expect("service identity address") ); let new_api_key = "rotated-resource-editor-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": new_api_key - }}) - .to_string(), - ); let request = input( root, Uuid::new_v4().to_string(), @@ -4816,13 +4873,16 @@ mod tests { ledger.remote_operation_id = Some("legacy-remote-operation".to_string()); write_resource_edit_ledger(root, &ledger).expect("write legacy ledger"); - let first = request_resource_edit_service_identity_confirmation_at( - RequestResourceEditServiceIdentityConfirmationInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - operation_id: request.operation_id.clone(), - }, - ) + let first = with_test_external_editor_credentials(&base_url, new_api_key, async { + request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + }) .await .expect("issue service identity challenge"); assert_eq!( @@ -4836,7 +4896,7 @@ mod tests { .expect("challenged ledger"); changed.request_body_json = Some("{\"prompt\":\"changed\"}".to_string()); write_resource_edit_ledger(root, &changed).expect("change challenged snapshot"); - let stale_error = + let stale_error = with_test_external_editor_credentials(&base_url, new_api_key, async { confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -4845,16 +4905,21 @@ mod tests { challenge: first.challenge, }) .await - .expect_err("changed ledger must invalidate challenge"); + }) + .await + .expect_err("changed ledger must invalidate challenge"); assert!(stale_error.contains("上下文已变化"), "{stale_error}"); - let second = request_resource_edit_service_identity_confirmation_at( - RequestResourceEditServiceIdentityConfirmationInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - operation_id: request.operation_id.clone(), - }, - ) + let second = with_test_external_editor_credentials(&base_url, new_api_key, async { + request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + }) .await .expect("rotate stale challenge"); let mut expired = read_resource_edit_ledger(root, &request.operation_id) @@ -4866,7 +4931,7 @@ mod tests { .expect("second private challenge") .expires_at = unix_timestamp(); write_resource_edit_ledger(root, &expired).expect("expire service identity challenge"); - let expired_error = + let expired_error = with_test_external_editor_credentials(&base_url, new_api_key, async { confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -4875,18 +4940,23 @@ mod tests { challenge: second.challenge, }) .await - .expect_err("expired challenge must fail closed"); + }) + .await + .expect_err("expired challenge must fail closed"); assert!(expired_error.contains("已过期"), "{expired_error}"); - let current = request_resource_edit_service_identity_confirmation_at( - RequestResourceEditServiceIdentityConfirmationInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - operation_id: request.operation_id.clone(), - }, - ) + let current = with_test_external_editor_credentials(&base_url, new_api_key, async { + request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + }) .await .expect("rotate expired challenge"); - let confirmed = + let confirmed = with_test_external_editor_credentials(&base_url, new_api_key, async { confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -4895,14 +4965,17 @@ mod tests { challenge: current.challenge, }) .await - .expect("confirm current service identity"); + }) + .await + .expect("confirm current service identity"); assert_eq!( confirmed.identity_scheme, PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept resumed poll"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "resumed poll fixture", 0); let request = read_http_request(&mut stream); assert!( request.starts_with("GET /api/external/v1/generations/legacy-remote-operation ") @@ -4923,13 +4996,16 @@ mod tests { let mut resumed = read_resource_edit_ledger(root, &request.operation_id) .expect("read confirmed ledger") .expect("confirmed ledger"); - wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - new_api_key, - &mut resumed, - ) + with_test_external_editor_credentials(&base_url, new_api_key, async { + wait_for_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + new_api_key, + &mut resumed, + ) + .await + }) .await .expect("resume accepted operation by GET"); server.join().expect("join resumed poll server"); @@ -4983,7 +5059,11 @@ mod tests { listener.local_addr().expect("submission address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept submission request"); + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "submission status fixture", + 0, + ); let request = read_http_request(&mut stream); assert!(request.starts_with("POST /api/external/v1/editor/videos/generations")); write_json( @@ -4992,13 +5072,16 @@ mod tests { serde_json::json!({"error": "provider detail must not persist"}), ); }); - let error = submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { + submit_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + }) .await .expect_err("submission status must fail"); server.join().expect("join submission fixture"); @@ -5099,7 +5182,11 @@ mod tests { listener.local_addr().expect("accepted response address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept submission request"); + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "accepted response fixture", + 0, + ); let request = read_http_request(&mut stream); assert!(request.starts_with("POST /api/external/v1/editor/videos/generations")); write_bytes( @@ -5109,13 +5196,16 @@ mod tests { response_body.as_bytes(), ); }); - let error = submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { + submit_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + }) .await .expect_err("unproven accepted response must fail closed"); server.join().expect("join accepted fixture"); @@ -5171,13 +5261,6 @@ mod tests { .expect("unavailable endpoint address") ); drop(unavailable); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "transport-failure-key" - }}) - .to_string(), - ); let mut request = input( root, Uuid::new_v4().to_string(), @@ -5189,7 +5272,10 @@ mod tests { request.source_subtype = Some("background-music".to_string()); request.producer_task_id = Some(task_id); - let error = derive_local_project_resource_at(request.clone()) + let error = + with_test_external_editor_credentials(&base_url, "transport-failure-key", async { + derive_local_project_resource_at(request.clone()).await + }) .await .expect_err("transport failure must fail closed"); assert!(error.contains("result-unknown"), "{error}"); @@ -5247,7 +5333,8 @@ mod tests { listener.local_addr().expect("failed status address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept failed status request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "failed status fixture", 0); let request = read_http_request(&mut stream); assert!(request.starts_with("GET /api/external/v1/generations/remote-terminal-failure")); write_json( @@ -5257,13 +5344,16 @@ mod tests { ); }); - let error = wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { + wait_for_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + }) .await .expect_err("failed status must become terminal"); server.join().expect("join failed status fixture"); @@ -5489,8 +5579,12 @@ mod tests { let server_generated_video = generated_video.clone(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..7 { - let (mut stream, _) = listener.accept().expect("accept External video request"); + for request_index in 0..7 { + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "External video fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -5584,9 +5678,6 @@ mod tests { } }); let api_key = "resource-editor-external-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), - ); let mut request = input( root, Uuid::new_v4().to_string(), @@ -5598,9 +5689,11 @@ mod tests { request.source_media_type = Some(source_asset.media_type.clone()); request.asset_name = "源视频编辑版".to_string(); - let result = derive_local_project_resource_at(request.clone()) - .await - .expect("derive External video"); + let result = with_test_external_editor_credentials(&base_url, api_key, async { + derive_local_project_resource_at(request.clone()).await + }) + .await + .expect("derive External video"); server.join().expect("join External video server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 7); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f5e808386..ada12e029 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14263,3 +14263,11 @@ - 本地 `master` 推送门禁 `scripts/check-repository-ci.sh` 必须在 lint 后运行 Jenkins Web Build 所覆盖的 AGC AppSurface 套件,再执行 build、content 和 diff 检查,避免登录 UI 回归只在远端构建阶段暴露;完整 Vitest 仍由 Jenkins 生产构建执行。 - Gitea `repository-checks` 因此必须和 Frontend / Native jobs 一样先执行 AGC 子包的 lockfile 安装;根目录 `npm ci` 不包含 `@tauri-apps/plugin-http` 等子包依赖,不能用预构建镜像缓存假定它们已存在。 - 验证:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "shows a clear login service error|explains a refused login connection|keeps the stored token when startup auth check cannot reach the service"`、`npm run check:git-hooks`、`npm run check:encoding`、`git diff --check`。 + +## 2026-08-19 AGC 画板恢复测试显式认证模式 + +- 背景:普通 Debug 客户端已默认走平台账号路由;旧画板恢复 fixture 只写 `editorApi` 配置,却未安装测试会话或任务级开发者凭据。请求会在到达 loopback mock 前以 `authentication-required` 返回,而 fixture 随后无限等待 `accept`,使 Native shell CI 无界卡住。 +- 决策:断言 External Editor `external-v1` 路由的测试必须通过 task-local 测试凭据显式进入开发者路径;断言普通客户端恢复路径的测试必须安装可自动恢复的测试平台会话并断言 `/api/runtime/external-generation/jobs/*`。所有等待 mock 请求的 fixture 必须使用有界 accept deadline,不得用无期限 `join` 掩盖请求前失败。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml canvas_generation_tests:: -- --test-threads=1`。 +- 2026-08-19 追加:同一默认认证切换也覆盖资源编辑和 autonomous main-loop fixture。`resource_editor` 的 External Editor 视频提交/轮询/服务身份恢复测试同样使用 task-local 凭据;平台账号语义测试使用隔离测试会话。main-loop 的视觉任务配置测试不再通过旧 `editorApi` 文件伪造登录态。所有 loopback listener 在 accept 时设有 5 秒 deadline,并把 accepted stream 恢复为 blocking,避免 Windows `WouldBlock(10035)` 或请求未发出时无限等待。 +- 定向验证:`project::asset_canvas::generation::tests::` 14/14、`project::resource_editor::tests::` 36/36、`agent::runtime_driver::main_loop_tests::` 48/48、`agent::runtime_protocol::autonomous_completion_contract_tests::` 107/107 通过。此前一次 Windows 全量 Native Rust 为 1811 passed、108 failed、15 ignored;失败集合仍包含 Provider/mock 调度与既有专业链断言。HEAD 基线独立复现 `tests::project::generate_platform_art_asset_downloads_and_registers_external_image` 的同一登录态缺失,故不能把全量结果伪报为本次 fixture 修复引入;本次新增认证/accept deadline 相关用例均已隔离通过,最后两个 autonomous completion fixture 的认证迁移已单独通过,完整套件未在该两行测试改动后重新执行。