回补 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
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user