回补 AGC 发布前工程栈校验与跳过构建的导出测试
- export.rs:npm 导出用例改名并扩断言,覆盖根 assets 补入与 dist 同名优先 - export.rs:新增 5 条发布前工程栈校验用例(Phaser4+Vite 通过、Phaser3 报可操作错误、缺依赖与非 Vite 失败、semver 区间与 lockfile 版本、无 npm build 工作区失败) - export.rs:新增 1 条端到端归一化 ZIP 用例(剥离 game 前缀、dist 产物优先、node_modules 与源码不入包、入口引用改相对) - export.rs:新增 1 条已有可玩入口时跳过 npm run build 的用例
This commit is contained in:
@@ -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::<Vec<_>>();
|
||||
// 构建工程(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 = "<!doctype html><html><body><canvas></canvas><script src=\"/assets/main.js\"></script></body></html>";
|
||||
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::<Vec<_>>();
|
||||
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<Cursor<Vec<u8>>>, 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"),
|
||||
"<!doctype html><html><body><canvas></canvas><script>window.started = true;</script></body></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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user