回补 AGC 客户端发布与导出路径的机器级覆盖
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m11s
Project CI / Backend tests (pull_request) Failing after 4m21s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m3s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m42s
Project CI / Repository checks (pull_request) Failing after 3m43s
Project CI / Native shell tests (pull_request) Successful in 6m13s

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 工作区脚手架的工程)。
This commit is contained in:
2026-10-04 01:18:29 +08:00
parent 0bf84e2886
commit c4b3d22042
@@ -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#"<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>月光弹幕厨房</title></head>
<body>
<canvas id="game" width="320" height="180"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const player = { x: 40, y: 90, hp: 4 };
let litPots = 0;
let state = 'playing';
const marker = 'MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect';
function resetGame() {
player.hp = 4;
litPots = 0;
state = 'playing';
}
function frame() {
ctx.fillStyle = '#101827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#facc15';
ctx.fillText(marker, 16, 32);
ctx.fillText('目标:反弹月光弹幕,点亮三口锅', 16, 58);
ctx.fillText('状态:继续 / 胜利 / 失败,R 重开', 16, 82);
if (litPots >= 3) state = '胜利';
if (player.hp <= 0) state = '失败';
requestAnimationFrame(frame);
}
window.addEventListener('keydown', (event) => {
if (event.key.toLowerCase() === 'r') resetGame();
});
requestAnimationFrame(frame);
</script>
</body>
</html>"#;
/// 单文件 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', '<!doctype html><html><head><meta charset="utf-8"><title>Auto Build</title><script src="assets/app.js"></script></head><body><h1>AUTO-BUILD</h1></body></html>');
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::<Vec<_>>();
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::<Vec<_>>();
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}");
}
}