Crafter.Build/tests/ProjectDependency/main.cpp
Jorijn van der Graaf e6363b95fe
test: specs and ProjectDependency() in a project file's static initialisers
ProjectArgs gets a sibling that makes its own sibling's spec at
namespace scope. Before the fix that spec took the args the file was
first loaded with; now it takes none on every load. The checks go
through one CheckLoaded helper, which prints what each sibling was
given, so a failure says which args a wrong frame handed over.

ProjectDependency loads a fixture whose static initialiser asks for its
pragma dependency, in a child, because a throw there terminates the
process: before the fix it found none. The check that the dependency
gets none of the consumer's args moves into the first load, which now
passes the app an arg it ignores; the separate load could not fail its
own check, and its early return skipped every later one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:11:41 +01:00

171 lines
9 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <stdlib.h>
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// `#pragma crafter dependency`: a project.cpp that imports a module from a
// sibling crafter-build project. LoadProject has to fetch and build that
// project before project.cpp compiles, put its BMI and archive on the
// project.so command line, and treat the archive as an input to the
// staleness check. The fixture is copied to a scratch dir because one case
// edits a source and every build drops bin/ and build/ trees next to it.
//
// A recompiled project.so can't be observed from the process that already
// dlopened the old one — the loader hands back the mapping it has for that
// path — so the "dependency changed" cases load through a child process:
// this binary re-run with `--load <project.cpp>` prints the name the project
// file produced.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// Expect LoadProject to throw; the message, or "" when it didn't.
std::string LoadFails(const fs::path& projectFile) {
std::array<std::string_view, 0> noArgs = {};
try {
(void)LoadProject(projectFile, noArgs);
} catch (const std::exception& e) {
return e.what();
}
return "";
}
// LoadProject in a fresh process; returns what it printed, trimmed.
std::string LoadInChild(const fs::path& projectFile) {
const fs::path self = fs::read_symlink("/proc/self/exe");
const auto r = RunCommandWithTimeout(std::format("{} --load {}", self.string(), projectFile.string()), std::chrono::seconds(300));
std::string out = r.output;
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
if (r.exitCode != 0) return std::format("<exit {}> {}", r.exitCode, out);
// The dependency build's progress lines share stdout; the name is the
// last line.
const std::size_t lastBreak = out.rfind('\n');
return lastBreak == std::string::npos ? out : out.substr(lastBreak + 1);
}
void ChangeGreeting(const fs::path& scratch) {
const fs::path iface = scratch / "greeter" / "interfaces" / "Greeter.cppm";
fs::copy_file(scratch / "greeter" / "interfaces" / "Greeter-changed.cppm.in", iface, fs::copy_options::overwrite_existing);
// copy_file carries the source's mtime across, which would leave the
// rewritten interface looking older than the BMI built from it.
fs::last_write_time(iface, fs::file_time_type::clock::now());
}
}
int main(int argc, char** argv) {
if (argc == 3 && std::string_view(argv[1]) == "--load") {
std::array<std::string_view, 0> noArgs = {};
Configuration cfg = LoadProject(argv[2], noArgs);
std::println("{}", cfg.name);
return 0;
}
const fs::path installedHome = GetCrafterBuildHome();
if (!fs::exists(installedHome / "Crafter.Build.cppm")) {
std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string());
return 77;
}
const fs::path fixture = fs::current_path() / "tests" / "ProjectDependency" / "fixture";
const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-dependency";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
fs::copy(fixture, scratch, fs::copy_options::recursive);
// The dependency's project.cpp applies the standard args, which read the
// march from the environment. project.so is compiled for the host, so
// the pre-pass must override it or the dependency's BMIs won't load.
setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1);
const fs::path app = scratch / "app" / "project.cpp";
const fs::path appSo = scratch / "app" / "build" / "project.so";
Check(IsTopLevelProject(), "outside any project file counts as top level");
// The dependency is fetched, built and linked before project.cpp runs,
// and ProjectDependency() hands it back by either name. The app ignores
// the arg it is loaded with, and the dependency must not get it: host
// tooling gets none of the consumer's args, although a LocalProject spec
// defaults to them. The consumer's flags would split the tool's variant
// from every other consumer's, and its --target= would have the tool's
// project.cpp choose for the wrong triple.
try {
std::array<std::string_view, 1> flavored = { "--flavor=x" };
Configuration cfg = LoadProject(app, flavored);
Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name));
Check(std::ranges::contains(cfg.compileFlags, "-DTOP_LEVEL=1"), "the project LoadProject was pointed at sees itself as top level");
Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name");
if (cfg.dependencies.size() == 2) {
Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency");
const Configuration& dep = *cfg.dependencies[0];
Check(dep.name == "greeter", "the dependency is the fixture's greeter project");
Check(dep.target == HostTarget(), "the dependency was built for the host triple LoadProject uses");
Check(dep.march == "native", "the environment's march was overridden for host tooling");
Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent");
Check(std::ranges::contains(dep.compileFlags, "-DTOP_LEVEL=0"), "a dependency's project file does not see itself as top level");
Check(dep.projectArgs.empty(), std::format("the dependency got none of the consumer's args (got {})", dep.projectArgs));
Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists");
}
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what());
return 1;
}
// A static initialiser of the project file runs while LoadProject
// dlopens it, before CrafterBuildProject is called, and can already ask
// for a dependency. In a child, because a static initialiser that
// throws terminates the process.
{
const std::string loaded = LoadInChild(scratch / "static-dep" / "project.cpp");
Check(loaded == "static greeter", std::format("ProjectDependency() works in a static initialiser (got '{}')", loaded));
}
Check(fs::exists(appSo), "project.so was produced");
if (!fs::exists(appSo)) return 1;
const fs::file_time_type firstSo = fs::last_write_time(appSo);
// Editing the dependency rebuilds it and recompiles project.so against
// it — without touching project.cpp.
ChangeGreeting(scratch);
const std::string changed = LoadInChild(app);
Check(changed == "hello again", std::format("a changed dependency reaches project.cpp (got '{}')", changed));
Check(fs::last_write_time(appSo) > firstSo, "project.so was recompiled after the dependency changed");
const fs::file_time_type secondSo = fs::last_write_time(appSo);
// And an unchanged dependency doesn't: "always stale" would be invisible
// to every other case here.
const std::string unchanged = LoadInChild(app);
Check(unchanged == "hello again", std::format("an unchanged dependency still loads (got '{}')", unchanged));
Check(fs::last_write_time(appSo) == secondSo, "project.so is left alone when nothing changed");
// Directive errors name the file and line.
const std::string noSource = LoadFails(scratch / "bad-nosource" / "project.cpp");
Check(noSource.contains("project.cpp:3:") && noSource.contains("expected a quoted source"), std::format("a directive without a source is rejected with its location (got '{}')", noSource));
const std::string both = LoadFails(scratch / "bad-both" / "project.cpp");
Check(both.contains("project.cpp:3:") && both.contains("mutually exclusive"), std::format("branch and commit together are rejected (got '{}')", both));
// Two project files that depend on each other stop instead of recursing.
const std::string cycle = LoadFails(scratch / "cycle-a" / "project.cpp");
Check(cycle.contains("circular project dependency"), std::format("a dependency cycle is reported (got '{}')", cycle));
// The accessor is only meaningful while a project file is running.
try {
(void)ProjectDependency("Greeter");
Check(false, "ProjectDependency outside a project file throws");
} catch (const std::exception& e) {
Check(std::string_view(e.what()).contains("outside a project file"), std::format("ProjectDependency outside a project file explains itself (got '{}')", e.what()));
}
return Failures > 0 ? 1 : 0;
}