Files
Khushboo Mehta 63780b2461 feat: manifest 0.4.0 — variant-independent icon in assets/
`icon` moves from a per-variant copy to a single assets/icon.png at the
  package root, so a host can read it without unpacking a platform build —
  the precondition for showing icons before install. Covered by the Merkle
  tree, so it is authenticated by manifest.sig for free.

  Adds `lgx add --icon` and Package::setIcon(). Validation (PNG, exactly
  256x256) runs at verify/sign, not at create/add — a package is
  legitimately incomplete mid-assembly.

  Required for type ui_qml only; core modules render no tile.

  Manifests below 0.4.0 are exempt: icon: "" was legal at 0.3.0 and is what
  lgx create defaulted to, so enforcing it would make every published
  package uninstallable
2026-08-10 11:01:16 +02:00

677 lines
23 KiB
C++

#include <gtest/gtest.h>
#include "core/package.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include "test_png.h"
#include <sstream>
namespace fs = std::filesystem;
class CLITest : public ::testing::Test {
protected:
fs::path tempDir;
fs::path lgxBinary;
void SetUp() override {
tempDir = fs::temp_directory_path() / ("lgx_cli_test_" + std::to_string(rand()));
fs::create_directories(tempDir);
// Check for LGX_BINARY environment variable first
const char* envBinary = std::getenv("LGX_BINARY");
if (envBinary && fs::exists(envBinary)) {
lgxBinary = envBinary;
return;
}
// Find the lgx binary - try multiple locations
std::vector<fs::path> searchPaths = {
fs::current_path().parent_path() / "lgx", // Running from tests/ subdir
fs::current_path() / "lgx", // Running from build root
fs::current_path() / "build" / "lgx", // Running from project root
fs::current_path().parent_path() / "build" / "lgx" // Running from build/tests
};
for (const auto& path : searchPaths) {
if (fs::exists(path)) {
lgxBinary = path;
return;
}
}
// Binary not found, skip tests
GTEST_SKIP() << "lgx binary not found. Set LGX_BINARY env var or tried: "
<< searchPaths[0] << ", " << searchPaths[1] << ", "
<< searchPaths[2] << ", " << searchPaths[3];
}
void TearDown() override {
std::error_code ec;
fs::remove_all(tempDir, ec);
}
// Helper to run lgx command
int runLgx(const std::string& args, std::string* output = nullptr) {
std::string cmd = lgxBinary.string() + " " + args;
if (output) {
cmd += " 2>&1";
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return -1;
char buffer[128];
while (fgets(buffer, sizeof(buffer), pipe)) {
*output += buffer;
}
int status = pclose(pipe);
return WEXITSTATUS(status);
} else {
int status = system(cmd.c_str());
return WEXITSTATUS(status);
}
}
};
// Test: lgx create <name>
// Verifies that the CLI can create a new skeleton package
TEST_F(CLITest, CreateCommand) {
fs::path pkgPath = tempDir / "test.lgx";
std::string output;
int exitCode = runLgx("create " + (tempDir / "test").string(), &output);
EXPECT_EQ(exitCode, 0);
EXPECT_TRUE(fs::exists(pkgPath));
EXPECT_NE(output.find("Created package"), std::string::npos);
}
// Test: lgx verify <valid-package>
// Verifies that the CLI correctly validates a well-formed package
// Commands: lgx create, lgx verify
TEST_F(CLITest, VerifyCommand_ValidPackage) {
fs::path pkgPath = tempDir / "test.lgx";
runLgx("create " + (tempDir / "test").string());
std::string output;
int exitCode = runLgx("verify " + pkgPath.string(), &output);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("valid"), std::string::npos);
}
// Test: lgx verify <invalid-package>
// Verifies that the CLI correctly rejects an invalid package file
// Commands: lgx verify
TEST_F(CLITest, VerifyCommand_InvalidPackage) {
fs::path invalidPkg = tempDir / "invalid.lgx";
std::ofstream(invalidPkg) << "not a valid package";
std::string output;
int exitCode = runLgx("verify " + invalidPkg.string(), &output);
EXPECT_NE(exitCode, 0); // Should fail
EXPECT_FALSE(output.empty()); // Should have error message
}
// Test: lgx add <pkg> --variant <v> --files <single-file> -y
// Verifies adding a single file to a variant and that package remains valid
// Commands: lgx create, lgx add, lgx verify
TEST_F(CLITest, AddCommand_SingleFile) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testFile = tempDir / "lib.so";
// Create package and test file
runLgx("create " + (tempDir / "test").string());
std::ofstream(testFile) << "test content";
// Add variant
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v linux-amd64 -f " +
testFile.string() + " -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Added"), std::string::npos);
// Verify still valid
exitCode = runLgx("verify " + pkgPath.string());
EXPECT_EQ(exitCode, 0);
}
// Test: lgx add <pkg> --variant <v> --files <directory> --main <path> -y
// Verifies adding a directory to a variant with explicit main entry
// Commands: lgx create, lgx add, lgx verify
TEST_F(CLITest, AddCommand_Directory) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testDir = tempDir / "dist";
// Create package and test directory
runLgx("create " + (tempDir / "test").string());
fs::create_directories(testDir);
std::ofstream(testDir / "index.js") << "console.log('hello')";
std::ofstream(testDir / "lib.js") << "export {}";
// Add variant with directory
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v web -f " +
testDir.string() + " --main index.js -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Added"), std::string::npos);
// Verify still valid
exitCode = runLgx("verify " + pkgPath.string());
EXPECT_EQ(exitCode, 0);
}
// Test: lgx add <pkg> --variant <existing-v> --files <new-file> -y
// Verifies variant replacement (no merge) - old content should be replaced
// Commands: lgx create, lgx add (twice), lgx verify
TEST_F(CLITest, AddCommand_ReplacesVariant) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path file1 = tempDir / "old.so";
fs::path file2 = tempDir / "new.so";
// Create package
runLgx("create " + (tempDir / "test").string());
// Add initial variant
std::ofstream(file1) << "old content";
runLgx("add " + pkgPath.string() + " -v linux-amd64 -f " +
file1.string() + " -y");
// Replace with new file
std::ofstream(file2) << "new content";
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v linux-amd64 -f " +
file2.string() + " -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Replaced"), std::string::npos);
// Verify package is still valid
exitCode = runLgx("verify " + pkgPath.string());
EXPECT_EQ(exitCode, 0);
}
// Test: lgx remove <pkg> --variant <v> -y
// Verifies removing a variant from a package
// Commands: lgx create, lgx add, lgx remove, lgx verify
TEST_F(CLITest, RemoveCommand) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testFile = tempDir / "lib.so";
// Setup: create package and add variant
runLgx("create " + (tempDir / "test").string());
std::ofstream(testFile) << "test content";
runLgx("add " + pkgPath.string() + " -v linux-amd64 -f " +
testFile.string() + " -y");
// Remove variant
std::string output;
int exitCode = runLgx(
"remove " + pkgPath.string() + " -v linux-amd64 -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Removed"), std::string::npos);
// Verify package is still valid (empty but valid)
exitCode = runLgx("verify " + pkgPath.string());
EXPECT_EQ(exitCode, 0);
}
// Test: lgx remove <pkg> --variant <nonexistent>
// Verifies error handling when removing non-existent variant
// Commands: lgx create, lgx remove
TEST_F(CLITest, RemoveCommand_NonExistent) {
fs::path pkgPath = tempDir / "test.lgx";
// Create empty package
runLgx("create " + (tempDir / "test").string());
// Try to remove non-existent variant
std::string output;
int exitCode = runLgx(
"remove " + pkgPath.string() + " -v nonexistent -y",
&output
);
EXPECT_NE(exitCode, 0); // Should fail
EXPECT_FALSE(output.empty()); // Should have error message
}
// Test: lgx --help
// Verifies that help text is displayed correctly
// Commands: lgx --help
TEST_F(CLITest, HelpCommand) {
std::string output;
int exitCode = runLgx("--help", &output);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Usage"), std::string::npos);
EXPECT_NE(output.find("create"), std::string::npos);
EXPECT_NE(output.find("add"), std::string::npos);
EXPECT_NE(output.find("remove"), std::string::npos);
EXPECT_NE(output.find("verify"), std::string::npos);
}
// Test: lgx --version
// Verifies that version information is displayed correctly
// Commands: lgx --version
TEST_F(CLITest, VersionCommand) {
std::string output;
int exitCode = runLgx("--version", &output);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("0.1.0"), std::string::npos);
}
// Test: lgx create <name> (when file already exists)
// Verifies error handling when trying to create over existing file
// Commands: lgx create (twice)
TEST_F(CLITest, CreateCommand_FileExists) {
fs::path pkgPath = tempDir / "test.lgx";
// Create first time
runLgx("create " + (tempDir / "test").string());
// Try to create again
std::string output;
int exitCode = runLgx("create " + (tempDir / "test").string(), &output);
EXPECT_NE(exitCode, 0); // Should fail
EXPECT_NE(output.find("exists"), std::string::npos);
}
// Test: lgx add <pkg> --variant <v> --files <directory> (without --main)
// Verifies error handling when adding directory without required --main flag
// Commands: lgx create, lgx add
TEST_F(CLITest, AddCommand_DirectoryWithoutMain) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testDir = tempDir / "dist";
// Create package and test directory
runLgx("create " + (tempDir / "test").string());
fs::create_directories(testDir);
std::ofstream(testDir / "file.txt") << "content";
// Try to add directory without --main
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v web -f " + testDir.string() + " -y",
&output
);
EXPECT_NE(exitCode, 0); // Should fail
EXPECT_NE(output.find("required"), std::string::npos);
}
TEST_F(CLITest, AddCommand_UiQmlDirectoryWithoutMain) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testDir = tempDir / "dist";
runLgx("create " + (tempDir / "test").string());
fs::create_directories(testDir / "qml");
std::ofstream(testDir / "qml" / "Main.qml") << "import QtQuick 2.15\nItem {}";
auto pkgOpt = lgx::Package::load(pkgPath);
ASSERT_TRUE(pkgOpt.has_value());
pkgOpt->getManifest().type = "ui_qml";
pkgOpt->getManifest().view = "qml/Main.qml";
auto saveResult = pkgOpt->save(pkgPath);
ASSERT_TRUE(saveResult.success);
// 0.4.0 requires ui_qml packages to carry a conforming icon.
const std::string iconPath =
lgx_test::writePng((tempDir / "icon.png").string());
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v darwin-arm64 -f " + testDir.string() +
" --icon " + iconPath + " -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Added"), std::string::npos);
exitCode = runLgx("verify " + pkgPath.string());
EXPECT_EQ(exitCode, 0);
}
TEST_F(CLITest, AddCommand_UiQmlDirectoryWithViewFlag) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testDir = tempDir / "dist";
runLgx("create " + (tempDir / "test").string());
fs::create_directories(testDir / "qml");
std::ofstream(testDir / "qml" / "Main.qml") << "import QtQuick 2.15\nItem {}";
auto pkgOpt = lgx::Package::load(pkgPath);
ASSERT_TRUE(pkgOpt.has_value());
pkgOpt->getManifest().type = "ui_qml";
auto saveResult = pkgOpt->save(pkgPath);
ASSERT_TRUE(saveResult.success);
// 0.4.0 requires ui_qml packages to carry a conforming icon.
const std::string iconPath =
lgx_test::writePng((tempDir / "icon.png").string());
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v darwin-arm64 -f " + testDir.string()
+ " --view qml/Main.qml --icon " + iconPath + " -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Added"), std::string::npos);
exitCode = runLgx("verify " + pkgPath.string());
EXPECT_EQ(exitCode, 0);
auto reloaded = lgx::Package::load(pkgPath);
ASSERT_TRUE(reloaded.has_value());
EXPECT_EQ(reloaded->getManifest().view, "qml/Main.qml");
}
TEST_F(CLITest, AddCommand_UiQmlDirectoryWithoutView) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path testDir = tempDir / "dist";
runLgx("create " + (tempDir / "test").string());
fs::create_directories(testDir);
std::ofstream(testDir / "file.txt") << "content";
auto pkgOpt = lgx::Package::load(pkgPath);
ASSERT_TRUE(pkgOpt.has_value());
pkgOpt->getManifest().type = "ui_qml";
auto saveResult = pkgOpt->save(pkgPath);
ASSERT_TRUE(saveResult.success);
// 0.4.0 requires ui_qml packages to carry a conforming icon.
const std::string iconPath =
lgx_test::writePng((tempDir / "icon.png").string());
std::string output;
int exitCode = runLgx(
"add " + pkgPath.string() + " -v darwin-arm64 -f " + testDir.string() +
" --icon " + iconPath + " -y",
&output
);
EXPECT_NE(exitCode, 0);
EXPECT_NE(output.find("view"), std::string::npos);
}
// =============================================================================
// Merge Command Tests
// =============================================================================
// Helper to create a single-variant .lgx package with metadata
void createSingleVariantPackage(
const std::string& lgxBinary,
const fs::path& pkgPath,
const std::string& name,
const std::string& variant,
const std::string& fileContent
) {
fs::path dir = pkgPath.parent_path();
fs::path tmpFile = dir / (variant + "_file.so");
// Create the package using the logical name (so manifest.name matches across packages)
fs::path createdPath = dir / (name + ".lgx");
std::string cmd = lgxBinary + " create " + (dir / name).string() + " 2>&1";
system(cmd.c_str());
if (createdPath != pkgPath) {
fs::rename(createdPath, pkgPath);
}
// Create a test file and add as variant
std::ofstream(tmpFile) << fileContent;
cmd = lgxBinary + " add " + pkgPath.string() +
" -v " + variant + " -f " + tmpFile.string() + " -y 2>&1";
system(cmd.c_str());
}
// Test: lgx merge <pkg1.lgx> <pkg2.lgx>
// Verifies basic merge of two single-variant packages
TEST_F(CLITest, MergeCommand_BasicMerge) {
fs::path pkg1 = tempDir / "pkg1.lgx";
fs::path pkg2 = tempDir / "pkg2.lgx";
fs::path merged = tempDir / "merged.lgx";
createSingleVariantPackage(lgxBinary.string(), pkg1, "test", "linux-amd64", "linux lib");
createSingleVariantPackage(lgxBinary.string(), pkg2, "test", "darwin-arm64", "darwin lib");
std::string output;
int exitCode = runLgx(
"merge " + pkg1.string() + " " + pkg2.string() +
" -o " + merged.string() + " -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Merged"), std::string::npos);
EXPECT_TRUE(fs::exists(merged));
// Verify the merged package is valid
exitCode = runLgx("verify " + merged.string(), &output);
EXPECT_EQ(exitCode, 0);
}
// Test: lgx merge with duplicate variants (should fail)
TEST_F(CLITest, MergeCommand_DuplicateVariantsFails) {
fs::path pkg1 = tempDir / "pkg1.lgx";
fs::path pkg2 = tempDir / "pkg2.lgx";
fs::path merged = tempDir / "merged.lgx";
createSingleVariantPackage(lgxBinary.string(), pkg1, "test", "linux-amd64", "lib v1");
createSingleVariantPackage(lgxBinary.string(), pkg2, "test", "linux-amd64", "lib v2");
std::string output;
int exitCode = runLgx(
"merge " + pkg1.string() + " " + pkg2.string() +
" -o " + merged.string() + " -y",
&output
);
EXPECT_NE(exitCode, 0);
EXPECT_NE(output.find("Duplicate variant"), std::string::npos);
}
// Test: lgx merge with --skip-duplicates
TEST_F(CLITest, MergeCommand_SkipDuplicates) {
fs::path pkg1 = tempDir / "pkg1.lgx";
fs::path pkg2 = tempDir / "pkg2.lgx";
fs::path merged = tempDir / "merged.lgx";
createSingleVariantPackage(lgxBinary.string(), pkg1, "test", "linux-amd64", "lib v1");
createSingleVariantPackage(lgxBinary.string(), pkg2, "test", "linux-amd64", "lib v2");
std::string output;
int exitCode = runLgx(
"merge " + pkg1.string() + " " + pkg2.string() +
" -o " + merged.string() + " --skip-duplicates -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("skipping"), std::string::npos);
EXPECT_TRUE(fs::exists(merged));
}
// Test: lgx merge with manifest mismatch (should fail)
TEST_F(CLITest, MergeCommand_ManifestMismatch) {
fs::path pkg1 = tempDir / "pkg1.lgx";
fs::path pkg2 = tempDir / "pkg2.lgx";
fs::path merged = tempDir / "merged.lgx";
createSingleVariantPackage(lgxBinary.string(), pkg1, "pkg1", "linux-amd64", "lib1");
createSingleVariantPackage(lgxBinary.string(), pkg2, "pkg2", "darwin-arm64", "lib2");
std::string output;
int exitCode = runLgx(
"merge " + pkg1.string() + " " + pkg2.string() +
" -o " + merged.string() + " -y",
&output
);
EXPECT_NE(exitCode, 0);
EXPECT_NE(output.find("mismatch"), std::string::npos);
}
// Test: lgx merge with too few arguments
TEST_F(CLITest, MergeCommand_TooFewArgs) {
fs::path pkg1 = tempDir / "pkg1.lgx";
createSingleVariantPackage(lgxBinary.string(), pkg1, "test", "linux-amd64", "lib");
std::string output;
int exitCode = runLgx("merge " + pkg1.string(), &output);
EXPECT_NE(exitCode, 0);
EXPECT_NE(output.find("At least two"), std::string::npos);
}
// Test: lgx merge three packages
TEST_F(CLITest, MergeCommand_ThreePackages) {
fs::path pkg1 = tempDir / "pkg1.lgx";
fs::path pkg2 = tempDir / "pkg2.lgx";
fs::path pkg3 = tempDir / "pkg3.lgx";
fs::path merged = tempDir / "merged.lgx";
createSingleVariantPackage(lgxBinary.string(), pkg1, "test", "linux-amd64", "linux amd64");
createSingleVariantPackage(lgxBinary.string(), pkg2, "test", "linux-arm64", "linux arm64");
createSingleVariantPackage(lgxBinary.string(), pkg3, "test", "darwin-arm64", "darwin arm64");
std::string output;
int exitCode = runLgx(
"merge " + pkg1.string() + " " + pkg2.string() + " " + pkg3.string() +
" -o " + merged.string() + " -y",
&output
);
EXPECT_EQ(exitCode, 0);
EXPECT_TRUE(fs::exists(merged));
// Verify the merged package is valid
exitCode = runLgx("verify " + merged.string(), &output);
EXPECT_EQ(exitCode, 0);
}
// =============================================================================
// Multi-variant package workflow
// =============================================================================
// Test: Multi-variant package workflow
// Verifies creating a package with multiple variants
// Commands: lgx create, lgx add (multiple), lgx verify
TEST_F(CLITest, MultiVariantWorkflow) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path linuxLib = tempDir / "lib_linux.so";
fs::path darwinLib = tempDir / "lib_darwin.dylib";
// Create package
runLgx("create " + (tempDir / "test").string());
// Add Linux variant
std::ofstream(linuxLib) << "linux library";
runLgx("add " + pkgPath.string() + " -v linux-amd64 -f " +
linuxLib.string() + " -y");
// Add Darwin variant
std::ofstream(darwinLib) << "darwin library";
runLgx("add " + pkgPath.string() + " -v darwin-arm64 -f " +
darwinLib.string() + " -y");
// Verify package with multiple variants
std::string output;
int exitCode = runLgx("verify " + pkgPath.string(), &output);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("valid"), std::string::npos);
}
// ── lgx signature ────────────────────────────────────────────────────────
//
// Contract pinned by these tests:
// * unsigned package → stdout empty, exit 0
// * signed package → stdout = manifest.sig JSON, exit 0
// * missing path → stderr message, exit non-zero
// Tooling (the out-of-CI index builder over in logos-modules-release-base)
// relies on the exit-status-not-stream-length convention to distinguish
// "no signature" from "error" — regressing that contract would silently
// flip every unsigned package in a self-hosted catalog into a failure
// during index build, hence the explicit tests below.
// Test: lgx signature on an unsigned package
// Expected: empty stdout, exit 0 (the "I'm fine, just unsigned" path).
TEST_F(CLITest, SignatureCommand_UnsignedPackage) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path lib = tempDir / "lib.so";
runLgx("create " + (tempDir / "test").string());
std::ofstream(lib) << "payload";
runLgx("add " + pkgPath.string() + " -v linux-amd64 -f " +
lib.string() + " -y");
std::string output;
int exitCode = runLgx("signature " + pkgPath.string(), &output);
EXPECT_EQ(exitCode, 0);
EXPECT_TRUE(output.empty())
<< "unsigned package must produce no stdout output; got: " << output;
}
// Test: lgx signature on a signed package
// Expected: stdout carries the raw manifest.sig JSON (must contain the
// signer's DID), exit 0. The content match is intentionally loose —
// looking for "did" / "signature" substrings is enough to confirm the
// blob came out, without coupling the test to the exact JSON layout.
TEST_F(CLITest, SignatureCommand_SignedPackage) {
fs::path pkgPath = tempDir / "test.lgx";
fs::path lib = tempDir / "lib.so";
fs::path keysDir = tempDir / "keys";
runLgx("create " + (tempDir / "test").string());
std::ofstream(lib) << "payload";
runLgx("add " + pkgPath.string() + " -v linux-amd64 -f " +
lib.string() + " -y");
runLgx("keygen --name testkey --output-dir " + keysDir.string());
int signExit = runLgx("sign " + pkgPath.string() +
" --key testkey --keys-dir " + keysDir.string());
ASSERT_EQ(signExit, 0) << "preflight: signing must succeed for this test";
std::string output;
int exitCode = runLgx("signature " + pkgPath.string(), &output);
EXPECT_EQ(exitCode, 0);
EXPECT_FALSE(output.empty()) << "signed package must produce stdout";
EXPECT_NE(output.find("\"did\""), std::string::npos)
<< "signature JSON should include a `did` field; got: " << output;
EXPECT_NE(output.find("\"signature\""), std::string::npos)
<< "signature JSON should include a `signature` field; got: " << output;
}
// Test: lgx signature with a missing path
// Expected: non-zero exit (the "real error, not unsigned" path).
TEST_F(CLITest, SignatureCommand_MissingPackage) {
std::string output;
int exitCode = runLgx(
"signature " + (tempDir / "does-not-exist.lgx").string(),
&output);
EXPECT_NE(exitCode, 0);
EXPECT_FALSE(output.empty()) << "missing package should print a message";
}