895 lines
37 KiB
C++
895 lines
37 KiB
C++
#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.unknownCommandFails") {
|
||
CaptureStreams cap;
|
||
const int rc = runCli({"eve", "frobnicate"});
|
||
CHECK(rc != 0);
|
||
CHECK(cap.all().find("not expected") != 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.buildMissingPlatformFails") {
|
||
CaptureStreams cap;
|
||
const int rc = runCli({"eve", "build"});
|
||
CHECK(rc == 2);
|
||
CHECK(cap.all().find("missing platform") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("cmdline.buildUnknownPlatformFails") {
|
||
CaptureStreams cap;
|
||
const int rc = runCli({"eve", "build", "-p", "nonsense"});
|
||
CHECK(rc == 2);
|
||
CHECK(cap.all().find("unknown platform") != std::string::npos);
|
||
}
|
||
|
||
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
|
||
|
||
{
|
||
CaptureStreams cap;
|
||
const int rc = runCli({"eve", "get", "android"});
|
||
REQUIRE(rc == 0);
|
||
CHECK(cap.out().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);
|
||
}
|
||
|
||
TEST_CASE("cmdline.getUnsupportedPlatformFails") {
|
||
CaptureStreams cap;
|
||
const int rc = runCli({"eve", "get", "ios"});
|
||
CHECK(rc == 2);
|
||
CHECK(cap.all().find("not implemented") != std::string::npos);
|
||
}
|