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 2fe8aa290..d55fdcd59 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 @@ -933,18 +933,26 @@ mod npm_export_tests { } #[test] - fn npm_package_contains_only_dist_and_publish_readme() { + fn npm_package_contains_dist_project_root_assets_and_publish_readme() { let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); fs::create_dir_all(&base).unwrap(); let root = tempfile::tempdir_in(base).unwrap(); - for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] { + for directory in [ + "dist/assets/uploads", + "assets/uploads", + "exports", + "node_modules", + "game", + ] { fs::create_dir_all(root.path().join(directory)).unwrap(); } for file in [ "package.json", "dist/index.html", "dist/assets/main.js", + "dist/assets/uploads/shared.png", "assets/hero.png", + "assets/uploads/shared.png", "exports/README.md", "node_modules/private.js", "game/source.js", @@ -956,13 +964,331 @@ mod npm_export_tests { .iter() .map(|(name, _, _)| name.as_str()) .collect::>(); + // 构建工程(dist 存在)的试玩包 = dist 构建产物(带 `game/` 前缀,归一化成 + // 发行包时剥离)+ 项目根 `assets/**` 中 dist 未包含的素材(AGC 上传素材以 + // `assets/uploads/...` 作为运行 URL,未必经 Vite public 复制进 dist) + // + 发布说明。`node_modules` 与 `game/` 源码不进包。 assert_eq!( names, vec![ + "assets/hero.png", "exports/README.md", "game/assets/main.js", - "game/index.html" + "game/assets/uploads/shared.png", + "game/index.html", ] ); + // 同名素材以 dist 构建产物为准:根 `assets/uploads/shared.png` 会被丢弃, + // 否则归一化剥离 `game/` 前缀后会与 dist 条目重名。 + let shared = files + .iter() + .find(|(name, _, _)| name == "game/assets/uploads/shared.png") + .expect("dist asset entry"); + assert_eq!( + shared.1, + root.path().join("dist/assets/uploads/shared.png"), + "同路径素材必须保留 dist 构建产物" + ); + } + + #[test] + fn built_project_publish_payload_includes_uploaded_assets_and_prefers_dist_assets() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + for directory in [ + "dist/assets/uploads", + "assets/uploads", + "exports", + "node_modules", + "game/src", + ] { + fs::create_dir_all(root.path().join(directory)).unwrap(); + } + // 构建入口用根绝对引用 `/assets/main.js`:发行包必须把它改写成发行目录内可用的 + // 相对引用,否则包根 index.html 取不到同一包内的资源。 + let built_entry = ""; + for (file, content) in [ + ("package.json", "{\"scripts\":{\"build\":\"vite build\"}}"), + ("dist/index.html", built_entry), + ("dist/assets/main.js", "dist-main"), + ("dist/assets/uploads/shared.png", "dist-shared"), + ("assets/uploads/food.png", "root-food"), + ("assets/uploads/shared.png", "root-shared"), + ("exports/README.md", "publish notes"), + ("node_modules/private.js", "private"), + ("game/src/app.js", "game source"), + ] { + fs::write(root.path().join(file), content).unwrap(); + } + + let exported = export_local_project_package_at(root.path()).expect("export built project"); + let payload = + read_local_project_export_package_at(root.path(), &exported.package_relative_path) + .expect("normalize publish package"); + + // 归一化后的发行包条目:`game/` 前缀已剥掉、dist 产物落在包根、项目根 `assets/**` + // 与 dist 独有路径并存、发布说明在包内;`node_modules` 与 `game/` 源码不入包。 + let names = payload + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + assert_eq!( + names, + vec![ + "assets/main.js", + "assets/uploads/food.png", + "assets/uploads/shared.png", + "exports/README.md", + "index.html", + ] + ); + assert!( + names.iter().all(|name| !name.starts_with("game/")), + "发行包不得保留试玩包的 game/ 前缀:{names:?}" + ); + + let mut archive = zip::ZipArchive::new(Cursor::new(payload.package_bytes)) + .expect("read normalized package"); + let read_entry = |archive: &mut zip::ZipArchive>>, name: &str| { + let mut bytes = Vec::new(); + archive + .by_name(name) + .unwrap_or_else(|error| panic!("{name}: {error}")) + .read_to_end(&mut bytes) + .expect("read entry"); + bytes + }; + // dist 未包含的 AGC 上传素材(运行 URL `assets/uploads/...`)必须补入发行包。 + assert_eq!( + read_entry(&mut archive, "assets/uploads/food.png"), + b"root-food" + ); + // 同路径素材以 dist 构建产物为准,根版本被丢弃(否则归一化后会重名)。 + assert_eq!( + read_entry(&mut archive, "assets/uploads/shared.png"), + b"dist-shared" + ); + assert_eq!(read_entry(&mut archive, "assets/main.js"), b"dist-main"); + assert_eq!( + read_entry(&mut archive, "exports/README.md"), + b"publish notes" + ); + let entry_html = + String::from_utf8(read_entry(&mut archive, "index.html")).expect("utf8 entry"); + assert!( + entry_html.contains("src=\"assets/main.js\""), + "{entry_html}" + ); + assert!(!entry_html.contains("\"/assets/"), "{entry_html}"); + // `node_modules` 与 `game/` 源码不得随包发布。 + for forbidden in ["node_modules/private.js", "src/app.js", "game/src/app.js"] { + assert!( + archive.by_name(forbidden).is_err(), + "{forbidden} 不应进入发行包" + ); + } + } +} + +#[cfg(test)] +mod publish_export_tests { + use super::*; + + /// 发布前工程栈校验与「已有可玩入口时跳过构建」的回归覆盖。 + /// + /// 这些用例原在 `src/tests/project.rs`,随 master `839051f53` 删除该文件、merge + /// `783ca014e` 采纳 master 侧而丢失,这里按原断言意图重建在现役测试布局内。 + fn publish_project_tempdir(prefix: &str) -> tempfile::TempDir { + crate::tests::canonical_test_tempdir(prefix) + } + + fn write_game_package_json(root: &Path, package: serde_json::Value) { + fs::write( + root.join("game/package.json"), + serde_json::to_string_pretty(&package).expect("serialize package json"), + ) + .expect("write package json"); + } + + #[test] + fn publish_stack_validation_accepts_scaffolded_phaser4_vite_project() { + let temporary = publish_project_tempdir("agc-publish-stack-ok-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-stack-ok", "栈校验通过项目") + .expect("project init"); + + ensure_publish_project_stack(root).expect("Phaser 4 + Vite 脚手架应通过发布前工程栈校验"); + } + + #[test] + fn publish_stack_validation_reports_actionable_error_for_phaser3() { + let temporary = publish_project_tempdir("agc-publish-stack-phaser3-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-stack-phaser3", "Phaser 3 项目") + .expect("project init"); + write_game_package_json( + root, + serde_json::json!({ + "name": "stack-phaser3", + "private": true, + "scripts": { "build": "vite build" }, + "dependencies": { "phaser": "^3.90.0" }, + "devDependencies": { "vite": "^6.2.0" } + }), + ); + + let error = ensure_publish_project_stack(root).expect_err("Phaser 3 必须被拦下"); + + assert!(error.contains("只支持 Phaser 4"), "{error}"); + assert!(error.contains("3.90.0"), "错误要带上当前声明版本:{error}"); + assert!( + error.contains("npm install phaser@4.2.1"), + "错误要给出可操作修复:{error}" + ); + // 错误文案只带项目内相对路径,不带本地绝对路径。 + assert!( + !error.contains(&root.to_string_lossy().to_string()), + "{error}" + ); + } + + #[test] + fn publish_stack_validation_requires_phaser_dependency_and_vite() { + let temporary = publish_project_tempdir("agc-publish-stack-missing-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-stack-missing", "缺依赖项目") + .expect("project init"); + write_game_package_json( + root, + serde_json::json!({ + "name": "stack-no-phaser", + "private": true, + "scripts": { "build": "vite build" }, + "devDependencies": { "vite": "^6.2.0" } + }), + ); + let error = ensure_publish_project_stack(root).expect_err("缺 phaser 必须被拦下"); + assert!(error.contains("未声明 phaser 依赖"), "{error}"); + + // Phaser 4 但没有 Vite(依赖与配置都不存在):错误必须指向 Vite。 + fs::remove_file(root.join("game/vite.config.js")).expect("remove scaffold vite config"); + write_game_package_json( + root, + serde_json::json!({ + "name": "stack-webpack", + "private": true, + "scripts": { "build": "webpack" }, + "dependencies": { "phaser": "4.2.1" } + }), + ); + let error = ensure_publish_project_stack(root).expect_err("非 Vite 工程必须被拦下"); + assert!(error.contains("vite"), "{error}"); + assert!(error.contains("Vite 构建"), "{error}"); + } + + #[test] + fn publish_stack_validation_reads_semver_ranges_and_lockfile_versions() { + let temporary = publish_project_tempdir("agc-publish-stack-semver-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-stack-semver", "版本区间项目") + .expect("project init"); + write_game_package_json( + root, + serde_json::json!({ + "name": "stack-semver", + "private": true, + "scripts": { "build": "vite build" }, + "dependencies": { "phaser": ">=4 <5" }, + "devDependencies": { "vite": "^6.2.0" } + }), + ); + ensure_publish_project_stack(root).expect(">=4 <5 属于 Phaser 4"); + + // 声明成 `*` 时以 package-lock.json 的解析版本为准。 + write_game_package_json( + root, + serde_json::json!({ + "name": "stack-star", + "private": true, + "scripts": { "build": "vite build" }, + "dependencies": { "phaser": "*" }, + "devDependencies": { "vite": "^6.2.0" } + }), + ); + fs::write( + root.join("game/package-lock.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "stack-star", + "lockfileVersion": 3, + "packages": { "": {}, "node_modules/phaser": { "version": "3.90.0" } } + })) + .expect("serialize lockfile"), + ) + .expect("write lockfile"); + let error = ensure_publish_project_stack(root).expect_err("锁文件里的 Phaser 3 也要拦下"); + assert!(error.contains("只支持 Phaser 4"), "{error}"); + } + + #[test] + fn publish_stack_validation_rejects_projects_without_npm_build_workspace() { + let temporary = publish_project_tempdir("agc-publish-stack-legacy-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-stack-legacy", "无 npm 构建项目") + .expect("project init"); + // 去掉 npm 工作区(没有 package.json 也就没有 build 脚本),单文件项目不属于客户端首版合同。 + fs::remove_file(root.join("game/package.json")).expect("remove game package json"); + + let error = ensure_publish_project_stack(root).expect_err("单文件项目必须失败关闭"); + assert!(error.contains("只支持 Phaser 4"), "{error}"); + } + + #[tokio::test] + async fn publish_export_skips_build_when_phaser_vite_entry_exists() { + let temporary = publish_project_tempdir("agc-publish-skip-build-"); + let root = temporary.path(); + init_local_game_project_at(root, "project-publish-skip", "已构建发布项目") + .expect("project init"); + // 已有可玩构建产物:发布会直接打包,不再触发 npm run build。 + fs::create_dir_all(root.join("game/dist")).expect("create dist directory"); + write_game_package_json( + root, + serde_json::json!({ + "name": "publish-skip-fixture", + "private": true, + "scripts": { "build": "vite build" }, + "dependencies": { "phaser": "4.2.1" }, + "devDependencies": { "vite": "^6.2.0" } + }), + ); + fs::write(root.join("game/vite.config.js"), "export default {};\n") + .expect("write vite config"); + fs::write( + root.join("game/dist/index.html"), + "", + ) + .expect("write playable html"); + + let result = export_local_project_package_for_publish_at(root) + .await + .expect("已有可玩入口且缺少 README 时应自动生成并直接导出"); + + assert!(result.package_relative_path.ends_with(".zip")); + let readme = + fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); + assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}"); + let payload = read_local_project_export_package_at(root, &result.package_relative_path) + .expect("read publishable package"); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + !log.contains("project.verify build"), + "已有可玩入口时不应触发构建:{log}" + ); } }