#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "cmdline/cmdline.h" #include "cmdline/sdk_tools.h" #include "cmdline/zip_writer.h" #include "common/Module.h" #include #include #include #include #include #include #include #include #include #include namespace { // Runs the cmdline module in-process with the given argv (argv[0] is the // program name) and returns the exit code. CWD-relative commands operate on // the current directory, so tests wrap this with PushDir. int runCli(const std::vector& args) { std::vector argv; argv.reserve(args.size()); for (const auto& a : args) argv.push_back(const_cast(a.c_str())); auto* cmd = eve::ModuleManager::requireInstance("Cmdline"); return cmd->runArgs(static_cast(argv.size()), argv.data()); } // RAII: chdir into `dir` for the lifetime of the object, restoring afterwards // so tests sharing a process cannot leak the working directory into each other. class PushDir { public: explicit PushDir(const std::filesystem::path& dir) : old_(std::filesystem::current_path()) { std::filesystem::current_path(dir); } ~PushDir() { std::error_code ec; std::filesystem::current_path(old_, ec); } private: std::filesystem::path old_; }; // Swaps cout/cerr buffers so the test can inspect what a command printed // (rang color codes are dropped automatically when the sink is not a TTY). class CaptureStreams { public: CaptureStreams() { cout_old_ = std::cout.rdbuf(cout_buf_.rdbuf()); cerr_old_ = std::cerr.rdbuf(cerr_buf_.rdbuf()); } ~CaptureStreams() { std::cout.rdbuf(cout_old_); std::cerr.rdbuf(cerr_old_); } std::string out() const { return cout_buf_.str(); } std::string err() const { return cerr_buf_.str(); } std::string all() const { return out() + err(); } private: std::ostringstream cout_buf_, cerr_buf_; std::streambuf* cout_old_ = nullptr; std::streambuf* cerr_old_ = nullptr; }; std::filesystem::path tempDir(const char* tag) { const auto p = std::filesystem::temp_directory_path() / tag; std::error_code ec; std::filesystem::remove_all(p, ec); std::filesystem::create_directories(p, ec); return p; } void writeFile(const std::filesystem::path& p, const std::string& content) { std::filesystem::create_directories(p.parent_path()); std::ofstream ofs(p, std::ios::binary | std::ios::trunc); ofs << content; } std::string readTextFile(const std::filesystem::path& p) { std::ifstream in(p, std::ios::binary); return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; } // Reads the ZIP central directory (EOCD -> central entries) and returns the // entry names in archive order. Returns {} when the file is not a valid ZIP. std::vector zipEntryNames(const std::filesystem::path& p) { std::ifstream in(p, std::ios::binary); if (!in) return {}; const std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (data.size() < 22) return {}; const size_t eocd = data.rfind("PK\x05\x06"); if (eocd == std::string::npos || data.size() - eocd < 22) return {}; const auto le16 = [&](size_t off) -> uint16_t { return static_cast(static_cast(data[off]) | (static_cast(data[off + 1]) << 8)); }; const auto le32 = [&](size_t off) -> uint32_t { return static_cast(static_cast(data[off]) | (static_cast(data[off + 1]) << 8) | (static_cast(data[off + 2]) << 16) | (static_cast(data[off + 3]) << 24)); }; const uint32_t cdSize = le32(eocd + 12); const uint32_t cdOffset = le32(eocd + 16); if (cdOffset > data.size() || cdSize > data.size() - cdOffset) return {}; std::vector names; size_t pos = cdOffset; const size_t end = static_cast(cdOffset) + cdSize; while (pos + 46 <= end && data.compare(pos, 4, "PK\x01\x02") == 0) { const size_t nameLen = le16(pos + 28); const size_t extraLen = le16(pos + 30); const size_t commentLen = le16(pos + 32); if (pos + 46 + nameLen + extraLen + commentLen > end) return {}; names.emplace_back(data.substr(pos + 46, nameLen)); pos += 46 + nameLen + extraLen + commentLen; } return names; } void setEnvVar(const char* name, const std::string& value) { #if defined(_WIN32) _putenv_s(name, value.c_str()); #else setenv(name, value.c_str(), 1); #endif } void unsetEnvVar(const char* name) { #if defined(_WIN32) _putenv_s(name, ""); #else unsetenv(name); #endif } // RAII env removal; restores the previous value (or leaves it unset) afterwards. class ScopedUnsetEnv { public: explicit ScopedUnsetEnv(const char* name) : name_(name) { const char* old = std::getenv(name); if (old) { had_ = true; old_ = old; unsetEnvVar(name); } } ~ScopedUnsetEnv() { if (had_) setEnvVar(name_.c_str(), old_); } private: std::string name_, old_; bool had_ = false; }; // RAII env override; restores the previous value (or removes it) afterwards so // tests sharing a process cannot leak settings into each other. class ScopedEnv { public: ScopedEnv(const char* name, const std::string& value) : name_(name) { const char* old = std::getenv(name); if (old) { had_ = true; old_ = old; } setEnvVar(name, value); } ~ScopedEnv() { if (had_) setEnvVar(name_.c_str(), old_); else unsetEnvVar(name_.c_str()); } private: std::string name_, old_; bool had_ = false; }; // A fake EVEngine SDK install: TARGET_PLATFORM marker, prebuilt lib/, and the // android APK template (platform/apk) exactly like `make sdk/android` ships. std::filesystem::path fakeSdkRoot(const char* tag, const char* platform) { const auto sdk = tempDir(tag); writeFile(sdk / "share" / "eve" / "TARGET_PLATFORM", std::string(platform) + "\n"); writeFile(sdk / "lib" / "libmain.so", "fake so"); writeFile(sdk / "lib" / "libSDL2.so", "fake so"); writeFile(sdk / "platform" / "apk" / "template.txt", "apk template"); writeFile(sdk / "platform" / "game-shell" / "config.nut", "width=800\n"); return sdk; } // A fake gradle distribution whose launcher records the invocation and writes // the APK output that `eve build android` checks for. unsignedOnly mimics a // release build without a signing config (AGP emits app-release-unsigned.apk). void writeFakeGradle(const std::filesystem::path& gradleHome, bool unsignedOnly = false) { const auto bin = gradleHome / "bin"; std::error_code ec; std::filesystem::create_directories(bin, ec); #if defined(_WIN32) writeFile(bin / "gradle.bat", "@echo off\r\n" "echo FAKE_GRADLE %* > gradle-invoked.txt\r\n" "mkdir app\\build\\outputs\\apk\\release 2>nul\r\n" "mkdir app\\build\\outputs\\apk\\debug 2>nul\r\n" "echo ok > app\\build\\outputs\\apk\\release\\" + std::string(unsignedOnly ? "app-release-unsigned.apk" : "app-release.apk") + "\r\n" "echo ok > app\\build\\outputs\\apk\\debug\\app-debug.apk\r\n"); #else writeFile(bin / "gradle", "#!/bin/sh\n" "echo \"FAKE_GRADLE $*\" > gradle-invoked.txt\n" "mkdir -p app/build/outputs/apk/release app/build/outputs/apk/debug\n" "echo ok > app/build/outputs/apk/release/" + std::string(unsignedOnly ? "app-release-unsigned.apk" : "app-release.apk") + "\n" "echo ok > app/build/outputs/apk/debug/app-debug.apk\n"); std::filesystem::permissions(bin / "gradle", std::filesystem::perms::owner_all | std::filesystem::perms::group_read | std::filesystem::perms::others_read, ec); #endif } // A fake sdkmanager whose only job is to exit 0 for --licenses and package // installs, so `eve get android` skips the real command-line-tools download. void writeFakeSdkmanager(const std::filesystem::path& sdkRoot) { const auto bin = sdkRoot / "cmdline-tools" / "latest" / "bin"; std::error_code ec; std::filesystem::create_directories(bin, ec); #if defined(_WIN32) writeFile(bin / "sdkmanager.bat", "@exit /b 0\r\n"); #else writeFile(bin / "sdkmanager", "#!/bin/sh\nexit 0\n"); std::filesystem::permissions(bin / "sdkmanager", std::filesystem::perms::owner_all | std::filesystem::perms::group_read | std::filesystem::perms::others_read, ec); #endif } // 版本号去前缀 v(share/eve/VERSION 里不带 v)。 std::string sdkVersionNoV() { std::string tag = eve::cmd::sdk::sdkVersionTag(); return tag.empty() ? std::string("0.0.0") : tag.substr(1); } // file:// URL(供 curl 读取本地 fake release)。 std::string fileUrl(const std::filesystem::path& p) { std::string s = p.lexically_normal().string(); std::replace(s.begin(), s.end(), '\\', '/'); if (s.empty() || s[0] != '/') s = "/" + s; return "file://" + s; } // 写一个和官方 zip-sdk.py 布局一致的 fake SDK 包: // 顶层 eve-sdk/android/...(含标记、lib、platform 模板)。 void writeFakeEveSdkZip(const std::filesystem::path& zipPath, const std::string& ver) { eve::cmdline::ZipWriter zw; REQUIRE(zw.open(zipPath.string())); const auto add = [&](const std::string& rel, const std::string& data) { REQUIRE(zw.addFile(rel, data.data(), data.size())); }; add("eve-sdk/android/share/eve/TARGET_PLATFORM", "android\n"); add("eve-sdk/android/share/eve/VERSION", ver + "\n"); add("eve-sdk/android/lib/libmain.so", "fake so"); add("eve-sdk/android/lib/libSDL2.so", "fake so"); add("eve-sdk/android/platform/apk/template.txt", "apk template"); add("eve-sdk/android/platform/game-shell/config.nut", "width=800\n"); REQUIRE(zw.finish()); } // 为 fake release 目录写 SHA256SUMS(correct=false 时放一个错误摘要)。 void writeSha256Sums(const std::filesystem::path& releaseDir, const std::filesystem::path& zipPath, bool correct) { const std::string hex = correct ? eve::cmd::sdk::fileSha256(zipPath.string()) : std::string(64, '0'); writeFile(releaseDir / "SHA256SUMS", hex + " " + zipPath.filename().string() + "\n"); } // 在 EVEngine SDK 安装根预置一个 android SDK(跳过下载路径)。 std::filesystem::path fakeInstalledEveSdk(const std::filesystem::path& installRoot) { const auto sdk = installRoot / "android"; writeFile(sdk / "share" / "eve" / "TARGET_PLATFORM", "android\n"); writeFile(sdk / "share" / "eve" / "VERSION", sdkVersionNoV() + "\n"); writeFile(sdk / "lib" / "libmain.so", "fake so"); writeFile(sdk / "lib" / "libSDL2.so", "fake so"); writeFile(sdk / "platform" / "apk" / "template.txt", "apk template"); writeFile(sdk / "platform" / "game-shell" / "config.nut", "width=800\n"); return sdk; } } // namespace TEST_CASE("cmdline.helpListsSubcommands") { CaptureStreams cap; const int rc = runCli({"eve", "--help"}); CHECK(rc == 0); CHECK(cap.out().find("Subcommands:") != std::string::npos); for (const char* sub : {"run", "create", "zip", "package", "dev", "doc", "clean", "test", "build", "get", "mcp", "language-server"}) CHECK(cap.out().find(sub) != std::string::npos); } TEST_CASE("cmdline.versionPrintsVersion") { CaptureStreams cap; const int rc = runCli({"eve", "--version"}); CHECK(rc == 0); CHECK(cap.out().find('v') == 0); } TEST_CASE("cmdline.invalidArgumentsReportErrors") { struct InvalidArguments { const char* name; std::vector arguments; int exitCode; // -1 means any nonzero code, matching the parser contract. const char* message; }; const InvalidArguments cases[] = { {"unknown command", {"eve", "frobnicate"}, -1, "not expected"}, {"missing platform", {"eve", "build"}, 2, "missing platform"}, {"unknown platform", {"eve", "build", "-p", "nonsense"}, 2, "unknown platform"}, {"unsupported platform", {"eve", "get", "ios"}, 2, "not implemented"}, }; for (const auto& input : cases) { std::cout << "CLI input: " << input.name << '\n'; int rc = 0; std::string output; { CaptureStreams cap; rc = runCli(input.arguments); output = cap.all(); } REQUIRE(rc != 0); if (input.exitCode != -1) REQUIRE_EQ(rc, input.exitCode); REQUIRE(output.find(input.message) != std::string::npos); } } TEST_CASE("cmdline.createScaffoldsGame") { const auto dir = tempDir("eve_ut_cmdline_create"); PushDir pd(dir); { CaptureStreams cap; const int rc = runCli({"eve", "create", "mygame"}); CHECK(rc == 0); CHECK(cap.out().find("Created mygame") != std::string::npos); } REQUIRE(std::filesystem::is_regular_file(dir / "mygame" / "config.nut")); REQUIRE(std::filesystem::is_regular_file(dir / "mygame" / "main.nut")); const std::string config = readTextFile(dir / "mygame" / "config.nut"); const std::string main = readTextFile(dir / "mygame" / "main.nut"); CHECK(config.find("modules = [\"gfx\"]") != std::string::npos); CHECK(main.find("persist boxX: float = 0.0") != std::string::npos); CHECK(main.find("eve_update = function(dt: float)") != std::string::npos); CHECK(main.find("getroottable") == std::string::npos); // A second run must not clobber existing files. { CaptureStreams cap; const int rc = runCli({"eve", "create", "mygame"}); CHECK(rc == 0); CHECK(cap.out().find("skip existing config.nut") != std::string::npos); } std::error_code ec; std::filesystem::remove_all(dir, ec); } TEST_CASE("cmdline.cleanDoesNotScaffoldProject") { // Regression: the clean handler used to call Create(), so `eve clean` // scaffolded a fresh "debug" project in the current directory. const auto dir = tempDir("eve_ut_cmdline_clean"); PushDir pd(dir); CaptureStreams cap; const int rc = runCli({"eve", "clean"}); CHECK(rc == 0); CHECK(cap.out().find("Clean debug build") != std::string::npos); CHECK(!std::filesystem::exists(dir / "debug")); CHECK(!std::filesystem::exists(dir / "config.nut")); std::error_code ec; std::filesystem::remove_all(dir, ec); } TEST_CASE("cmdline.docPrintsUrlAndSymbol") { // Regression: the doc handler looked up the "create" subcommand, so // `eve doc` never ran. --no-open keeps the test (and headless/CI calls) // from spawning a browser. CaptureStreams cap; const int rc = runCli({"eve", "doc", "--no-open", "gfx.drawSolidRect"}); CHECK(rc == 0); CHECK(cap.out().find("EVEngine docs for 'gfx.drawSolidRect'") != std::string::npos); CHECK(cap.out().find("https://evengine.github.io/EVEngine/") != std::string::npos); CaptureStreams cap2; const int rc2 = runCli({"eve", "doc", "--no-open"}); CHECK(rc2 == 0); CHECK(cap2.out().find("EVEngine docs:") != std::string::npos); } TEST_CASE("cmdline.zipDefaultNamesArchiveAfterCwd") { // Regression: `eve zip` with no path (default ".") used to write a file // literally named "..eve" instead of .eve. const auto dir = tempDir("eve_ut_cmdline_zip_default"); PushDir pd(dir); writeFile(dir / "main.nut", "eve_init = function() {}\n"); CaptureStreams cap; const int rc = runCli({"eve", "zip"}); CHECK(rc == 0); const std::string expected = dir.filename().string() + ".eve"; CHECK(cap.out().find(expected) != std::string::npos); CHECK(!std::filesystem::exists(dir / "..eve")); REQUIRE(std::filesystem::is_regular_file(dir / expected)); std::error_code ec; std::filesystem::remove_all(dir, ec); } TEST_CASE("cmdline.zipProducesValidArchiveWithoutItself") { const auto dir = tempDir("eve_ut_cmdline_zip_entries"); PushDir pd(dir); writeFile(dir / "main.nut", "eve_init = function() {}\n"); writeFile(dir / "scripts" / "a.nut", "a <- 1\n"); CaptureStreams cap; const int rc = runCli({"eve", "zip"}); CHECK(rc == 0); const std::string archive = dir.filename().string() + ".eve"; REQUIRE(std::filesystem::is_regular_file(dir / archive)); const auto names = zipEntryNames(dir / archive); REQUIRE(names.size() == 2); // Directory iteration order is not guaranteed, so compare sorted sets. std::vector sorted = names; std::sort(sorted.begin(), sorted.end()); CHECK(sorted[0] == "main.nut"); CHECK(sorted[1] == "scripts/a.nut"); // The writer must never embed the output archive into itself. for (const auto& n : names) CHECK(n != archive); std::error_code ec; std::filesystem::remove_all(dir, ec); } TEST_CASE("cmdline.buildAndroidAssemblesApkFromSdk") { const auto sdk = fakeSdkRoot("eve_ut_cmdline_sdk_android", "android"); const auto tools = tempDir("eve_ut_cmdline_android_tools"); writeFakeGradle(tools / "gradle-8.5"); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", tools.string()); ScopedEnv javaEnv("JAVA_HOME", tools.string()); ScopedEnv gradleEnv("GRADLE_HOME", (tools / "gradle-8.5").string()); const auto game = tempDir("eve_ut_cmdline_game"); writeFile(game / "main.nut", "eve_init = function() {}\n"); writeFile(game / "scripts" / "a.nut", "a <- 1\n"); const auto out = tempDir("eve_ut_cmdline_apk_out"); CaptureStreams cap; const int rc = runCli( {"eve", "build", "android", game.string(), "--sdk", sdk.string(), "-o", out.string()}); CHECK(rc == 0); // Game assets + prebuilt libs + sdk.dir landed in the assembled project. std::error_code ec; REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "src" / "main" / "assets" / "game" / "main.nut", ec)); REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "src" / "main" / "assets" / "game" / "scripts" / "a.nut", ec)); REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "src" / "main" / "jniLibs" / "arm64-v8a" / "libmain.so", ec)); REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "src" / "main" / "jniLibs" / "arm64-v8a" / "libSDL2.so", ec)); REQUIRE(std::filesystem::is_regular_file(out / "apk" / "local.properties", ec)); { std::ifstream in(out / "apk" / "local.properties"); std::string s((std::istreambuf_iterator(in)), std::istreambuf_iterator()); std::string expected = tools.string(); for (char& c : expected) if (c == '\\') c = '/'; CHECK(s.find("sdk.dir=" + expected) != std::string::npos); } // Fake gradle ran with the release task and the APK path was reported. REQUIRE(std::filesystem::is_regular_file(out / "apk" / "gradle-invoked.txt", ec)); { std::ifstream in(out / "apk" / "gradle-invoked.txt"); std::string s; std::getline(in, s); CHECK(s.find("assembleRelease") != std::string::npos); } REQUIRE(cap.out().find("app-release.apk") != std::string::npos); // Debug variant picks the assembleDebug task. CaptureStreams cap2; const int rc2 = runCli({"eve", "build", "-d", "android", game.string(), "--sdk", sdk.string(), "-o", out.string()}); CHECK(rc2 == 0); { std::ifstream in(out / "apk" / "gradle-invoked.txt"); std::string s; std::getline(in, s); CHECK(s.find("assembleDebug") != std::string::npos); } REQUIRE(cap2.out().find("app-debug.apk") != std::string::npos); std::filesystem::remove_all(sdk, ec); std::filesystem::remove_all(tools, ec); std::filesystem::remove_all(game, ec); std::filesystem::remove_all(out, ec); } TEST_CASE("cmdline.buildWrongSdkPlatformFails") { const auto sdk = fakeSdkRoot("eve_ut_cmdline_sdk_win32", "win32"); const auto tools = tempDir("eve_ut_cmdline_android_tools_wrong"); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", tools.string()); CaptureStreams cap; const int rc = runCli({"eve", "build", "android", "--sdk", sdk.string()}); CHECK(rc == 2); CHECK(cap.all().find("not android") != std::string::npos); std::error_code ec; std::filesystem::remove_all(sdk, ec); std::filesystem::remove_all(tools, ec); } TEST_CASE("cmdline.buildAndroidReportsUnsignedApk") { const auto sdk = fakeSdkRoot("eve_ut_cmdline_sdk_unsigned", "android"); const auto tools = tempDir("eve_ut_cmdline_android_tools_unsigned"); writeFakeGradle(tools / "gradle-8.5", /*unsignedOnly=*/true); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", tools.string()); ScopedEnv javaEnv("JAVA_HOME", tools.string()); ScopedEnv gradleEnv("GRADLE_HOME", (tools / "gradle-8.5").string()); const auto game = tempDir("eve_ut_cmdline_game_unsigned"); writeFile(game / "main.nut", "eve_init = function() {}\n"); const auto out = tempDir("eve_ut_cmdline_apk_out_unsigned"); CaptureStreams cap; const int rc = runCli( {"eve", "build", "android", game.string(), "--sdk", sdk.string(), "-o", out.string()}); REQUIRE(rc == 0); REQUIRE(cap.out().find("app-release-unsigned.apk") != std::string::npos); REQUIRE(cap.out().find("unsigned") != std::string::npos); std::error_code ec; std::filesystem::remove_all(sdk, ec); std::filesystem::remove_all(tools, ec); std::filesystem::remove_all(game, ec); std::filesystem::remove_all(out, ec); } TEST_CASE("cmdline.buildMissingAndroidSdkFails") { const auto sdk = fakeSdkRoot("eve_ut_cmdline_sdk_missing_tools", "android"); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", (sdk / "no-tools").string()); CaptureStreams cap; const int rc = runCli({"eve", "build", "android", "--sdk", sdk.string()}); CHECK(rc == 2); CHECK(cap.all().find("eve get android") != std::string::npos); std::error_code ec; std::filesystem::remove_all(sdk, ec); } TEST_CASE("cmdline.getAndroidAlreadyInstalled") { const auto sdk = tempDir("eve_ut_cmdline_android_installed"); writeFakeSdkmanager(sdk); writeFakeGradle(sdk / "gradle-8.5"); const auto installRoot = tempDir("eve_ut_cmdline_eve_sdk_installed"); fakeInstalledEveSdk(installRoot); ScopedEnv rootEnv("EVE_SDK_INSTALL_ROOT", installRoot.string()); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", sdk.string()); ScopedEnv javaEnv("JAVA_HOME", sdk.string()); // non-empty -> skip JDK download CaptureStreams cap; const int rc = runCli({"eve", "get", "android"}); CHECK(rc == 0); CHECK(cap.out().find("already installed") != std::string::npos); std::error_code ec; REQUIRE(std::filesystem::is_regular_file(sdk / "eve-android.env", ec)); { std::ifstream in(sdk / "eve-android.env"); std::string s((std::istreambuf_iterator(in)), std::istreambuf_iterator()); CHECK(s.find("GRADLE_HOME=") != std::string::npos); CHECK(s.find("ANDROID_HOME=") != std::string::npos); CHECK(s.find("EVENGINE_SDK=") != std::string::npos); } std::filesystem::remove_all(sdk, ec); std::filesystem::remove_all(installRoot, ec); } TEST_CASE("cmdline.sdkVersionTagNormalizesDevBuild") { // 当前 dev 构建版本是 v0.1.0-dev,归一化后应得到干净的 release tag。 const std::string tag = eve::cmd::sdk::sdkVersionTag(); CHECK(tag.rfind("v", 0) == 0); CHECK(tag.find("dev") == std::string::npos); CHECK(tag.find('-') == std::string::npos); // EVE_SDK_TAG 覆盖(不得重复加 v)。 ScopedEnv tagEnv("EVE_SDK_TAG", "v9.8.7"); CHECK(eve::cmd::sdk::sdkVersionTag() == "v9.8.7"); } TEST_CASE("cmdline.getAndroidInstallsEveSdkFromRelease") { // 端到端:EVE_SDK_BASE_URL 指向本地 file:// fake release,get 应下载、 // 校验 SHA256 并把 zip 里的 eve-sdk/android 安装到 EVE_SDK_INSTALL_ROOT。 const std::string tag = eve::cmd::sdk::sdkVersionTag(); const std::string ver = sdkVersionNoV(); const auto rel = tempDir("eve_ut_cmdline_release"); const auto zipPath = rel / ("eve-sdk-android-" + tag + ".zip"); writeFakeEveSdkZip(zipPath, ver); writeSha256Sums(rel, zipPath, /*correct=*/true); const auto tools = tempDir("eve_ut_cmdline_android_tools_get"); writeFakeSdkmanager(tools); writeFakeGradle(tools / "gradle-8.5"); const auto installRoot = tempDir("eve_ut_cmdline_eve_sdk_install"); ScopedEnv baseEnv("EVE_SDK_BASE_URL", fileUrl(rel)); ScopedEnv tagEnv("EVE_SDK_TAG", tag); ScopedEnv rootEnv("EVE_SDK_INSTALL_ROOT", installRoot.string()); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", tools.string()); ScopedEnv javaEnv("JAVA_HOME", tools.string()); // skip JDK download int installRc = -1; std::string installOutput; { CaptureStreams cap; installRc = runCli({"eve", "get", "android"}); installOutput = cap.all(); } if (installRc != 0) std::cerr << installOutput; REQUIRE(installRc == 0); CHECK(installOutput.find("installed at") != std::string::npos); const auto eveSdk = installRoot / "android"; std::error_code ec; REQUIRE(std::filesystem::is_regular_file( eveSdk / "share" / "eve" / "TARGET_PLATFORM", ec)); REQUIRE(std::filesystem::is_regular_file(eveSdk / "share" / "eve" / "VERSION", ec)); REQUIRE(std::filesystem::is_regular_file(eveSdk / "lib" / "libmain.so", ec)); REQUIRE(std::filesystem::is_regular_file( eveSdk / "platform" / "apk" / "template.txt", ec)); { std::ifstream in(eveSdk / "share" / "eve" / "VERSION"); std::string s; std::getline(in, s); CHECK(s == ver); } // env 文件记录 EVENGINE_SDK,供 `eve build android` 发现安装的 SDK。 REQUIRE(std::filesystem::is_regular_file(tools / "eve-android.env", ec)); { std::ifstream in(tools / "eve-android.env"); std::string s((std::istreambuf_iterator(in)), std::istreambuf_iterator()); CHECK(s.find("EVENGINE_SDK=" + eveSdk.string()) != std::string::npos); CHECK(s.find("ANDROID_HOME=") != std::string::npos); CHECK(s.find("GRADLE_HOME=") != std::string::npos); } // 幂等:第二次运行不重新下载,直接复用已安装 SDK。 { CaptureStreams cap; const int rc = runCli({"eve", "get", "android"}); REQUIRE(rc == 0); CHECK(cap.out().find("already installed") != std::string::npos); } std::filesystem::remove_all(rel, ec); std::filesystem::remove_all(tools, ec); std::filesystem::remove_all(installRoot, ec); } TEST_CASE("cmdline.getAndroidChecksumMismatchFails") { const std::string tag = eve::cmd::sdk::sdkVersionTag(); const auto rel = tempDir("eve_ut_cmdline_release_bad"); const auto zipPath = rel / ("eve-sdk-android-" + tag + ".zip"); writeFakeEveSdkZip(zipPath, sdkVersionNoV()); writeSha256Sums(rel, zipPath, /*correct=*/false); const auto installRoot = tempDir("eve_ut_cmdline_eve_sdk_install_bad"); ScopedEnv baseEnv("EVE_SDK_BASE_URL", fileUrl(rel)); ScopedEnv tagEnv("EVE_SDK_TAG", tag); ScopedEnv rootEnv("EVE_SDK_INSTALL_ROOT", installRoot.string()); CaptureStreams cap; const int rc = runCli({"eve", "get", "android"}); REQUIRE(rc == 3); CHECK(cap.all().find("SHA-256 verification failed") != std::string::npos); std::error_code ec; REQUIRE(!std::filesystem::exists(installRoot / "android", ec)); std::filesystem::remove_all(rel, ec); std::filesystem::remove_all(installRoot, ec); } TEST_CASE("cmdline.getAndroidEmptyReleaseResponseReturnsStructuredError") { // A successful HTTP/file transfer may still contain zero bytes (for // example, a truncated mirror response). The installer must reject that // response before ZIP parsing and expose a diagnostic through the C++ API. const std::string tag = eve::cmd::sdk::sdkVersionTag(); const auto rel = tempDir("eve_ut_cmdline_release_empty"); const auto zipPath = rel / ("eve-sdk-android-" + tag + ".zip"); writeFile(zipPath, std::string{}); writeFile(rel / "SHA256SUMS", ""); const auto installRoot = tempDir("eve_ut_cmdline_eve_sdk_install_empty"); ScopedEnv baseEnv("EVE_SDK_BASE_URL", fileUrl(rel)); ScopedEnv tagEnv("EVE_SDK_TAG", tag); ScopedEnv rootEnv("EVE_SDK_INSTALL_ROOT", installRoot.string()); { CaptureStreams cap; auto result = eve::cmd::sdk::installEveSdk(eve::cmd::sdk::Platform::Android); REQUIRE(!result.ok()); CHECK(result.code() == eve::StatusCode::Failed); const auto* diagnostic = result.error(); REQUIRE(diagnostic != nullptr); CHECK(diagnostic->code() == eve::DiagnosticCode::Failed); CHECK(diagnostic->path().find("eve-sdk-android-" + tag + ".zip") != std::string::npos); } // The CLI projection must also fail cleanly, without publishing an SDK. CaptureStreams cap; const int rc = runCli({"eve", "get", "android"}); REQUIRE(rc == 3); CHECK(cap.all().find("empty or unreadable response") != std::string::npos); std::error_code ec; CHECK(!std::filesystem::exists(installRoot / "android", ec)); std::filesystem::remove_all(rel, ec); std::filesystem::remove_all(installRoot, ec); } TEST_CASE("cmdline.buildAndroidUsesGetInstalledSdk") { // `eve get android` 安装的 SDK 应能被 build 自动发现(无 --sdk / EVENGINE_SDK)。 const auto installRoot = tempDir("eve_ut_cmdline_eve_sdk_use"); fakeInstalledEveSdk(installRoot); const auto tools = tempDir("eve_ut_cmdline_android_tools_use"); writeFakeGradle(tools / "gradle-8.5"); ScopedEnv rootEnv("EVE_SDK_INSTALL_ROOT", installRoot.string()); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", tools.string()); ScopedEnv javaEnv("JAVA_HOME", tools.string()); ScopedEnv gradleEnv("GRADLE_HOME", (tools / "gradle-8.5").string()); ScopedUnsetEnv unsetSdk("EVENGINE_SDK"); const auto game = tempDir("eve_ut_cmdline_game_use_get"); writeFile(game / "main.nut", "eve_init = function() {}\n"); const auto out = tempDir("eve_ut_cmdline_apk_out_use_get"); CaptureStreams cap; const int rc = runCli({"eve", "build", "android", game.string(), "-o", out.string()}); REQUIRE(rc == 0); std::error_code ec; REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "src" / "main" / "assets" / "game" / "main.nut", ec)); REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "src" / "main" / "jniLibs" / "arm64-v8a" / "libmain.so", ec)); REQUIRE(cap.out().find("app-release.apk") != std::string::npos); std::filesystem::remove_all(installRoot, ec); std::filesystem::remove_all(tools, ec); std::filesystem::remove_all(game, ec); std::filesystem::remove_all(out, ec); } TEST_CASE("cmdline.buildAndroidSkipsTemplateBuildOutputs") { // 官方 SDK 的 platform/apk 模板在发布流水线里被 test-sdk.sh 编译过, // app/build 下会残留 CI 测试产物;组装工程不应继承它们。 const auto sdk = fakeSdkRoot("eve_ut_cmdline_sdk_stale", "android"); writeFile(sdk / "platform" / "apk" / "app" / "build" / "stale-marker.txt", "ci test output"); const auto tools = tempDir("eve_ut_cmdline_android_tools_stale"); writeFakeGradle(tools / "gradle-8.5"); ScopedEnv sdkEnv("EVENGINE_ANDROID_SDK", tools.string()); ScopedEnv javaEnv("JAVA_HOME", tools.string()); ScopedEnv gradleEnv("GRADLE_HOME", (tools / "gradle-8.5").string()); const auto game = tempDir("eve_ut_cmdline_game_stale"); writeFile(game / "main.nut", "eve_init = function() {}\n"); const auto out = tempDir("eve_ut_cmdline_apk_out_stale"); CaptureStreams cap; const int rc = runCli( {"eve", "build", "android", game.string(), "--sdk", sdk.string(), "-o", out.string()}); REQUIRE(rc == 0); std::error_code ec; // 模板的 app/build 未被复制进组装工程;gradle 照常执行并产出自己的输出。 REQUIRE(!std::filesystem::exists(out / "apk" / "app" / "build" / "stale-marker.txt", ec)); REQUIRE(std::filesystem::is_regular_file( out / "apk" / "app" / "build" / "outputs" / "apk" / "release" / "app-release.apk", ec)); // SDK 模板本身保持不变(只跳过复制,不修改 SDK)。 REQUIRE(std::filesystem::is_regular_file( sdk / "platform" / "apk" / "app" / "build" / "stale-marker.txt", ec)); std::filesystem::remove_all(sdk, ec); std::filesystem::remove_all(tools, ec); std::filesystem::remove_all(game, ec); std::filesystem::remove_all(out, ec); } TEST_CASE("cmdline.packageUsesTargetSdkRuntimeAndBundledDlls") { // 跨平台打包:win32 SDK 的包用 eve.exe + SDK 内置的运行时 DLL;linux SDK // 的包用 eve。宿主是哪个平台不应影响产物。 const auto game = tempDir("eve_ut_cmdline_pkg_game"); writeFile(game / "main.nut", "eve_init = function() {}\n"); // win32 目标 SDK:运行时 + 内置 DLL(vulkan 加载器 / VC 运行库)。 const auto winSdk = tempDir("eve_ut_cmdline_pkg_sdk_win32"); writeFile(winSdk / "share" / "eve" / "TARGET_PLATFORM", "win32\n"); writeFile(winSdk / "bin" / "eve.exe", "win runtime"); writeFile(winSdk / "bin" / "vulkan-1.dll", "loader"); writeFile(winSdk / "bin" / "vcruntime140.dll", "crt"); const auto winOut = tempDir("eve_ut_cmdline_pkg_out_win32"); { CaptureStreams cap; const int rc = runCli( {"eve", "package", game.string(), "--sdk", winSdk.string(), "-o", winOut.string()}); REQUIRE(rc == 0); std::error_code ec; REQUIRE(std::filesystem::is_regular_file(winOut / "eve.exe", ec)); REQUIRE(std::filesystem::is_regular_file(winOut / "vulkan-1.dll", ec)); REQUIRE(std::filesystem::is_regular_file(winOut / "vcruntime140.dll", ec)); REQUIRE(std::filesystem::is_regular_file(winOut / "game.eve", ec)); REQUIRE(!std::filesystem::exists(winOut / "eve", ec)); CHECK(cap.out().find("eve.exe") != std::string::npos); } // linux 目标 SDK:运行时名是 eve(不是宿主平台的 eve.exe)。 const auto linSdk = tempDir("eve_ut_cmdline_pkg_sdk_linux"); writeFile(linSdk / "share" / "eve" / "TARGET_PLATFORM", "linux\n"); writeFile(linSdk / "bin" / "eve", "linux runtime"); const auto linOut = tempDir("eve_ut_cmdline_pkg_out_linux"); { CaptureStreams cap; const int rc = runCli( {"eve", "package", game.string(), "--sdk", linSdk.string(), "-o", linOut.string()}); REQUIRE(rc == 0); std::error_code ec; REQUIRE(std::filesystem::is_regular_file(linOut / "eve", ec)); REQUIRE(std::filesystem::is_regular_file(linOut / "game.eve", ec)); REQUIRE(!std::filesystem::exists(linOut / "eve.exe", ec)); } std::error_code ec; std::filesystem::remove_all(game, ec); std::filesystem::remove_all(winSdk, ec); std::filesystem::remove_all(winOut, ec); std::filesystem::remove_all(linSdk, ec); std::filesystem::remove_all(linOut, ec); } TEST_CASE("cmdline.packageRejectsInvalidScriptDependencyGraphs") { const auto sdk = tempDir("eve_ut_cmdline_pkg_scan_sdk"); writeFile(sdk / "share" / "eve" / "TARGET_PLATFORM", "linux\n"); writeFile(sdk / "bin" / "eve", "linux runtime"); const auto missing = tempDir("eve_ut_cmdline_pkg_scan_missing"); writeFile(missing / "main.nut", "import { value } from \"./missing.nut\"\n"); const auto missingOut = tempDir("eve_ut_cmdline_pkg_scan_missing_out"); { CaptureStreams cap; const int rc = runCli( {"eve", "package", missing.string(), "--sdk", sdk.string(), "-o", missingOut.string()}); CHECK_EQ(rc, 5); CHECK(cap.all().find("is missing from package") != std::string::npos); std::error_code ec; CHECK(!std::filesystem::exists(missingOut / "game.eve", ec)); } const auto cyclic = tempDir("eve_ut_cmdline_pkg_scan_cycle"); writeFile(cyclic / "main.nut", "import { a } from \"./a.nut\"\n"); writeFile(cyclic / "a.nut", "import { b } from \"./b.nut\"\nexport const a = b\n"); writeFile(cyclic / "b.nut", "import { a } from \"./a.nut\"\nexport const b = a\n"); const auto cyclicOut = tempDir("eve_ut_cmdline_pkg_scan_cycle_out"); { CaptureStreams cap; const int rc = runCli( {"eve", "package", cyclic.string(), "--sdk", sdk.string(), "-o", cyclicOut.string()}); CHECK_EQ(rc, 5); CHECK(cap.all().find("cyclic script import in package") != std::string::npos); } std::error_code ec; std::filesystem::remove_all(sdk, ec); std::filesystem::remove_all(missing, ec); std::filesystem::remove_all(missingOut, ec); std::filesystem::remove_all(cyclic, ec); std::filesystem::remove_all(cyclicOut, ec); }