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 BuildAUTO-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}");
+ }
}