From c4b3d2204239e59043ff8b86fa88762303c19c06 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 01:18:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E8=A1=A5=20AGC=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=8F=91=E5=B8=83=E4=B8=8E=E5=AF=BC=E5=87=BA=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=9A=84=E6=9C=BA=E5=99=A8=E7=BA=A7=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master 839051f53(PR #568 退役 AGC 自建 Agent Runtime/CLI)整文件删除了 src-tauri/src/tests/project.rs(当时含 128 个用例),本分支合并 783ca014e 采纳了 这次删除,导致其中覆盖「客户端发布/导出」活代码的断言失去全部引用。本次不复活已删 文件,把同域用例按原断言意图重建在 project/export.rs 的现役测试模块。生产代码未改动。 mod publish_export_tests 本次新增: - publish_build_plan_prefers_game_subproject_and_requires_dependencies:构建计划优先 game 子工程; 缺 game/node_modules 时 needs_dependency_install=true,补齐后为 false。 - publish_build_plan_falls_back_to_root_npm_build:game 无 build 脚本时回落到项目根 npm 工程 (cwd_relative="."、command 取根 build 脚本、不需要装依赖)。 - publish_export_runs_project_build_before_packaging:原型完成后发布先跑项目 npm build 产出 dist/ 再打包,且 .agent/logs/command.log 必须记录一次 project.verify build。 - publish_export_blocks_build_until_runnable_prototype_is_completed:原型未完成时禁止触发用户 项目构建(无 build 标记文件、无 build 日志、错误含「首个可运行原型尚未完成」)。 - publish_export_reports_actionable_error_without_entry_or_build_script:缺可玩入口且没有 build 脚本时失败关闭,错误必须同时给出「还没有可玩入口」与「build 脚本」。 - local_project_export_package_publish_payload_contains_bytes_and_file_digests:发行载荷带 package_size_bytes、64 位十六进制 sha256 与逐文件清单;条目无 game/ 前缀、含 index.html 与 exports/README.md(体积/契约)。 - local_project_export_package_list_only_returns_recent_playtest_zips:包列表只返回 exports/playtest-package-*.zip,按修改时间倒序,忽略非 playtest 文件与非 exports 目录。 - local_project_export_package_list_skips_symlink_packages(cfg(unix)):符号链接试玩包不得列出(安全)。 - local_project_export_package_rejects_symlink_assets(cfg(unix)):assets/ 内符号链接素材必须 整包拒收,且不得留下试玩包(安全)。 - local_project_export_package_rejects_symlink_runtime_dirs_and_readme(cfg(unix)):README 与 运行时目录被符号链接顶替时必须拒收(安全)。 同批提交中另含上一轮已重建的 7 条(同一文件): publish_stack_validation_{accepts_scaffolded_phaser4_vite_project, reports_actionable_error_for_phaser3,requires_phaser_dependency_and_vite, reads_semver_ranges_and_lockfile_versions,rejects_projects_without_npm_build_workspace}、 publish_export_skips_build_when_phaser_vite_entry_exists、 built_project_publish_payload_includes_uploaded_assets_and_prefers_dist_assets。 不适用 / 已覆盖: - publish_export_skips_build_when_playable_entry_exists(原始版本)已由 publish_export_skips_build_when_phaser_vite_entry_exists 以 Phaser 4 + Vite 语境等价覆盖,不重复。 - 其余域(preview / assets / commands / windows / cli 等)缺口不在本 PR 交付面,未处理。 fixture 适配:unique_project_path 在 HEAD 为私有 fn,改用 crate::tests::canonical_test_tempdir; 随文件删除的 init_existing_html_project_at 与 fake_llm_game_draft 用最小内联 fixture 取代 (内联单文件可玩 HTML + 去掉 npm 工作区脚手架的工程)。 --- .../src-tauri/src/project/export.rs | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 1937bb784..4896eaa65 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -1290,4 +1290,390 @@ mod publish_export_tests { "已有可玩入口时不应触发构建:{log}" ); } + + /// 单文件可玩 HTML:原 `tests/mod.rs::fake_llm_game_draft().game_html` 的最小内联副本, + /// 满足 `validate_game_html_smoke`(画布、主循环、输入、目标/胜负/重开文案、自包含)。 + const PLAYABLE_GAME_HTML: &str = r#" + + 月光弹幕厨房 + + + + +"#; + + /// 单文件 HTML 项目(非构建布局):脚手架提供 manifest 与目录,再去掉 npm 工作区脚手架, + /// 换成可玩入口。等价于已删除的 `tests/mod.rs::init_existing_html_project_at`。 + fn init_existing_html_project(root: &Path, project_id: &str, name: &str) { + init_local_game_project_at(root, project_id, name).expect("project init"); + for relative in [ + "game/package.json", + "game/package-lock.json", + "game/vite.config.js", + "game/style.css", + "game/game.js", + ] { + let _ = fs::remove_file(root.join(relative)); + } + fs::write(root.join("game/index.html"), PLAYABLE_GAME_HTML).expect("write playable html"); + } + + #[test] + fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() { + let temporary = publish_project_tempdir("agc-publish-plan-game-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-plan-game", "Phaser 工程").expect("project init"); + + // 脚手架是 game/ + vite build(Phaser 4 工程):缺依赖时必须先 install。 + let plan = resolve_publish_build_plan(root).expect("解析构建计划"); + assert_eq!(plan.cwd_relative, "game"); + assert_eq!(plan.command, "vite build"); + assert!( + plan.needs_dependency_install, + "缺少 game/node_modules 时应先装依赖" + ); + + fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules"); + let installed = resolve_publish_build_plan(root).expect("解析构建计划"); + assert!( + !installed.needs_dependency_install, + "已有依赖时不应重复 install" + ); + } + + #[test] + fn publish_build_plan_falls_back_to_root_npm_build() { + let temporary = publish_project_tempdir("agc-publish-plan-root-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-plan-root", "根工程构建").expect("project init"); + // 去掉 game 子工程的 build,改用项目根 npm 工程构建。 + write_game_package_json( + root, + serde_json::json!({ + "name": "plan-root-game", + "private": true, + "scripts": { "check": "node -e \"process.exit(0)\"" } + }), + ); + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "plan-root-fixture", + "private": true, + "scripts": { "build": "node build-root.mjs" } + })) + .expect("serialize root package json"), + ) + .expect("write root package json"); + + let plan = resolve_publish_build_plan(root).expect("解析构建计划"); + assert_eq!(plan.cwd_relative, "."); + assert_eq!(plan.command, "node build-root.mjs"); + assert!(!plan.needs_dependency_install); + } + + #[tokio::test] + async fn publish_export_runs_project_build_before_packaging() { + let temporary = publish_project_tempdir("agc-publish-auto-build-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-auto-build", "自动构建发布项目") + .expect("project init"); + // 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。 + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "publish-auto-build-fixture", + "private": true, + "scripts": { "build": "node build-publish.mjs" } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + fs::write( + root.join("build-publish.mjs"), + r#"import { mkdirSync, writeFileSync } from 'node:fs'; +mkdirSync('dist/assets', { recursive: true }); +writeFileSync('dist/index.html', 'Auto Build

AUTO-BUILD

'); +writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";'); +"#, + ) + .expect("write build script"); + update_manifest_task_status_at( + root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + ) + .expect("mark prototype completed"); + write_local_project_file_at(root, "exports/README.md", "publish notes") + .expect("write readme"); + + let result = export_local_project_package_for_publish_at(root) + .await + .expect("发布前构建并导出"); + + assert!(root.join("dist/index.html").is_file()); + assert!(root.join("dist/assets/app.js").is_file()); + assert!(result + .package_relative_path + .starts_with("exports/playtest-package-")); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + log.contains("project.verify build"), + "发布前应记录一次 project.verify build:{log}" + ); + } + + #[tokio::test] + async fn publish_export_blocks_build_until_runnable_prototype_is_completed() { + let temporary = publish_project_tempdir("agc-publish-prototype-pending-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-prototype-pending", "原型未完成项目") + .expect("project init"); + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "publish-prototype-pending-fixture", + "private": true, + "scripts": { "build": "node build-should-not-run.mjs" } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + fs::write( + root.join("build-should-not-run.mjs"), + "import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');", + ) + .expect("write build marker script"); + + let error = export_local_project_package_for_publish_at(root) + .await + .expect_err("原型未完成时禁止触发用户项目构建"); + + assert!( + error.contains("首个可运行原型尚未完成"), + "错误应说明原型未完成:{error}" + ); + assert!(!root.join("build-ran.txt").exists()); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + !log.contains("project.verify build"), + "原型未完成时不应执行项目构建:{log}" + ); + } + + #[tokio::test] + async fn publish_export_reports_actionable_error_without_entry_or_build_script() { + let temporary = publish_project_tempdir("agc-publish-no-entry-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-no-entry", "缺少可玩入口项目") + .expect("project init"); + update_manifest_task_status_at( + root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + ) + .expect("mark prototype completed"); + // 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。 + write_game_package_json( + root, + serde_json::json!({ + "name": "publish-no-entry-fixture", + "private": true, + "scripts": { "check": "node -e \"process.exit(0)\"" } + }), + ); + + let error = export_local_project_package_for_publish_at(root) + .await + .expect_err("缺少入口且没有 build 脚本时必须失败关闭"); + + assert!( + error.contains("还没有可玩入口"), + "错误应说明缺少可玩入口:{error}" + ); + assert!( + error.contains("build 脚本"), + "错误应指向 build 脚本:{error}" + ); + } + + #[test] + fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { + let temporary = publish_project_tempdir("agc-publish-payload-"); + let root = temporary.path(); + init_existing_html_project(root, "project-publish", "在线试玩项目"); + write_local_project_file_at(root, "exports/README.md", "publish notes") + .expect("write readme"); + + let exported = export_local_project_package_at(root).expect("export package"); + assert_eq!( + fs::read_to_string(root.join("exports/README.md")).unwrap(), + "publish notes" + ); + let payload = read_local_project_export_package_at(root, &exported.package_relative_path) + .expect("read publish payload"); + + assert_eq!( + payload.package_relative_path, + exported.package_relative_path + ); + assert_eq!( + payload.package_size_bytes, + payload.package_bytes.len() as u64 + ); + assert_eq!(payload.files.len(), 2); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes)) + .expect("read normalized package"); + let names = (0..archive.len()) + .map(|index| { + archive + .by_index(index) + .expect("normalized entry") + .name() + .to_string() + }) + .collect::>(); + assert!(names.iter().any(|name| name == "index.html")); + assert!(!names.iter().any(|name| name.starts_with("game/"))); + assert_eq!(payload.package_sha256.len(), 64); + assert!(payload + .package_sha256 + .chars() + .all(|value| value.is_ascii_hexdigit())); + } + + #[test] + fn local_project_export_package_list_only_returns_recent_playtest_zips() { + let temporary = publish_project_tempdir("agc-export-list-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-1", "像素动作原型").expect("project init"); + fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip"); + fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip"); + fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip"); + fs::write(root.join("game/playtest-package-003.zip"), "wrong dir") + .expect("write wrong dir zip"); + + let result = list_local_project_export_packages_at(root).expect("list packages"); + + assert_eq!(result.project_path, root.to_string_lossy().into_owned()); + let paths = result + .packages + .iter() + .map(|package| package.package_relative_path.as_str()) + .collect::>(); + assert_eq!( + paths, + vec![ + "exports/playtest-package-002.zip", + "exports/playtest-package-001.zip" + ] + ); + assert_eq!(result.packages[0].total_bytes, 5); + assert!(result.packages[0].modified_at > 0); + } + + #[cfg(unix)] + #[test] + fn local_project_export_package_list_skips_symlink_packages() { + let temporary = publish_project_tempdir("agc-export-list-link-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-1", "像素动作原型").expect("project init"); + fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip"); + std::os::unix::fs::symlink( + root.join("memory/project.md"), + root.join("exports/playtest-package-link.zip"), + ) + .expect("create package symlink"); + + let result = list_local_project_export_packages_at(root).expect("list packages"); + + assert_eq!(result.packages.len(), 1); + assert_eq!( + result.packages[0].package_relative_path, + "exports/playtest-package-real.zip" + ); + } + + #[cfg(unix)] + #[test] + fn local_project_export_package_rejects_symlink_assets() { + let temporary = publish_project_tempdir("agc-export-link-assets-"); + let root = temporary.path(); + init_existing_html_project(root, "project-1", "像素动作原型"); + write_local_project_file_at(root, "exports/README.md", "playtest notes") + .expect("write readme"); + std::os::unix::fs::symlink( + root.join("memory/project.md"), + root.join("assets/private.md"), + ) + .expect("create symlink asset"); + + let error = export_local_project_package_at(root).expect_err("symlink should be rejected"); + + assert!(error.contains("试玩包不能包含符号链接")); + assert!(!root.join("exports").join("playtest-package-").exists()); + } + + #[cfg(unix)] + #[test] + fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { + let temporary = publish_project_tempdir("agc-export-link-runtime-"); + let root = temporary.path(); + init_existing_html_project(root, "project-1", "像素动作原型"); + write_local_project_file_at(root, "memory/project.md", "private memory") + .expect("write memory"); + std::os::unix::fs::symlink( + root.join("memory/project.md"), + root.join("exports/README.md"), + ) + .expect("create readme symlink"); + + let error = export_local_project_package_at(root).expect_err("readme symlink rejected"); + + assert!(error.contains("符号链接"), "{error}"); + fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink"); + write_local_project_file_at(root, "exports/README.md", "playtest notes") + .expect("write readme"); + fs::remove_dir_all(root.join("assets")).expect("remove assets dir"); + fs::create_dir_all(root.join("memory/assets")).expect("create memory assets"); + fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset"); + std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets")) + .expect("create assets symlink"); + + let error = export_local_project_package_at(root).expect_err("assets symlink rejected"); + + assert!(error.contains("符号链接"), "{error}"); + } }