Files

899 lines
37 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <algorithm>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <streambuf>
#include <string>
#include <vector>
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<std::string>& args) {
std::vector<char*> argv;
argv.reserve(args.size());
for (const auto& a : args) argv.push_back(const_cast<char*>(a.c_str()));
auto* cmd = eve::ModuleManager::requireInstance<eve::cmd::Cmdline>("Cmdline");
return cmd->runArgs(static_cast<unsigned>(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<char>(in), std::istreambuf_iterator<char>()};
}
// 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<std::string> zipEntryNames(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return {};
const std::string data((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
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<uint16_t>(static_cast<unsigned char>(data[off]) |
(static_cast<unsigned char>(data[off + 1]) << 8));
};
const auto le32 = [&](size_t off) -> uint32_t {
return static_cast<uint32_t>(static_cast<unsigned char>(data[off]) |
(static_cast<unsigned char>(data[off + 1]) << 8) |
(static_cast<unsigned char>(data[off + 2]) << 16) |
(static_cast<unsigned char>(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<std::string> names;
size_t pos = cdOffset;
const size_t end = static_cast<size_t>(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<std::string> 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 <current-dir-name>.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<std::string> 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<char>(in)), std::istreambuf_iterator<char>());
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<char>(in)), std::istreambuf_iterator<char>());
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<char>(in)), std::istreambuf_iterator<char>());
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);
}