bajia/tests/unit_tests.cpp

452 lines
18 KiB
C++
Raw Permalink Normal View History

2026-08-28 00:22:49 +01:00
// unit_tests.cpp - host-side unit tests for the bajia parser and pure helpers.
#include "../src/config.cpp"
#include "../src/logger.cpp"
#include "../src/supervisor.cpp"
#include <cstdio>
2026-08-28 23:24:19 +01:00
#include <chrono>
2026-08-28 00:22:49 +01:00
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
using namespace bajia;
namespace {
int g_checks = 0;
int g_failures = 0;
#define CHECK(cond) \
do { \
++g_checks; \
if (!(cond)) { \
++g_failures; \
std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
#define CHECK_EQ(a, b) \
do { \
++g_checks; \
const auto va = (a); \
const auto vb = (b); \
if (!(va == vb)) { \
++g_failures; \
std::printf("FAIL %s:%d: %s != %s\n", __FILE__, __LINE__, #a, \
#b); \
} \
} while (0)
#define CHECK_THROWS(expr, substr) \
do { \
++g_checks; \
bool caught = false; \
try { \
expr; \
} catch (const std::exception& e) { \
caught = true; \
if (std::string(e.what()).find(substr) == std::string::npos) { \
++g_failures; \
std::printf("FAIL %s:%d: threw wrong error: %s\n", __FILE__, \
__LINE__, e.what()); \
} \
} \
if (!caught) { \
++g_failures; \
std::printf("FAIL %s:%d: did not throw: %s\n", __FILE__, __LINE__, \
#expr); \
} \
} while (0)
// In-memory replacement for the production opener: imports resolve against a
// logical file tree instead of the real filesystem.
struct VirtualFS {
std::map<std::string, std::string> files;
std::unique_ptr<std::istream> operator()(const std::string& path) const {
auto it = files.find(path);
if (it == files.end())
return nullptr;
return std::make_unique<std::istringstream>(it->second);
}
};
Config parse_string(const std::string& text, const VirtualFS& fs,
const std::string& name = "/t/main.rc") {
Config cfg;
std::vector<std::string> chain, seen;
std::istringstream in(text);
parse_rc_stream(cfg, in, name, chain, seen, fs);
return cfg;
}
void test_tokenize() {
CHECK(tokenize("").empty());
CHECK(tokenize(" \t ").empty());
CHECK(tokenize("# only a comment").empty());
CHECK(tokenize("; semicolon comment").empty());
CHECK_EQ(tokenize("a b c").size(), 3u);
CHECK_EQ(tokenize("a \t b").size(), 2u);
CHECK_EQ(tokenize("service foo /bin/true ").size(), 3u);
CHECK_EQ(tokenize("a # rest is a comment").size(), 1u);
CHECK_EQ(tokenize("a ; rest is a comment").size(), 1u);
// quoted strings survive as one token
CHECK_EQ(tokenize("write /x \"hello world\"").size(), 3u);
// escaped characters inside quotes
const auto t = tokenize("\"a\\\"b\" c");
CHECK_EQ(t.size(), 2u);
CHECK_EQ(t[0], std::string("a\"b"));
// unterminated quote: best-effort keeps what we have
CHECK_EQ(tokenize("\"unterminated").size(), 1u);
}
void test_parse_respawn() {
CHECK(parse_respawn("always") == RespawnPolicy::Always);
CHECK(parse_respawn("on-failure") == RespawnPolicy::OnFailure);
CHECK(parse_respawn("never") == RespawnPolicy::Never);
CHECK(parse_respawn("bogus") == RespawnPolicy::Never);
}
void test_resolve_import_path() {
CHECK_EQ(resolve_import_path("/a/b/main.rc", "sub/x.rc"),
std::string("/a/b/sub/x.rc"));
CHECK_EQ(resolve_import_path("/a/b/main.rc", "/abs/y.rc"),
std::string("/abs/y.rc"));
CHECK_EQ(resolve_import_path("main.rc", "x.rc"), std::string("x.rc"));
CHECK_EQ(resolve_import_path("/a/main.rc", ""), std::string(""));
}
void test_parse_basic() {
VirtualFS fs;
auto cfg = parse_string(
"service sshd /usr/sbin/sshd\n"
" user = root\n"
" respawn = on-failure\n"
" class = main\n"
" setenv = FOO=bar\n"
" oneshot\n"
" disabled\n"
" console\n"
"on early-init\n"
" mkdir /dev/pts 0755\n"
" log hello\n",
fs);
CHECK_EQ(cfg.services.size(), 1u);
CHECK_EQ(cfg.actions.size(), 1u);
const auto& svc = cfg.services[0];
CHECK_EQ(svc.name, std::string("sshd"));
CHECK_EQ(svc.args.size(), 1u);
CHECK_EQ(svc.args[0], std::string("/usr/sbin/sshd"));
CHECK_EQ(svc.uid, std::string("root"));
CHECK_EQ(svc.service_class, std::string("main"));
CHECK(svc.respawn == RespawnPolicy::OnFailure);
CHECK(svc.oneshot);
CHECK(svc.disabled);
CHECK(svc.console);
CHECK_EQ(svc.env.size(), 1u);
const auto& act = cfg.actions[0];
CHECK_EQ(act.trigger, std::string("early-init"));
CHECK_EQ(act.commands.size(), 2u);
CHECK(act.commands[0].kind == Command::Kind::Mkdir);
CHECK(act.commands[1].kind == Command::Kind::Log);
// option values require `= value`
CHECK_THROWS(parse_string("service s /bin/true\n respawn always\n", fs),
"requires '= value'");
// unknown option keys are silently ignored (by design)
CHECK_EQ(parse_string("service s /bin/true\n color = red\n", fs).services.size(),
1u);
// service needs name + executable
CHECK_THROWS(parse_string("service lonely\n", fs), "'service' requires");
// action needs a trigger
CHECK_THROWS(parse_string("on\n", fs), "'on' requires a trigger");
// unknown command inside an action
CHECK_THROWS(parse_string("on boot\n frobnicate /x\n", fs),
"unknown action command");
// unknown directive at column 0
CHECK_THROWS(parse_string("BROKEN = yes\n", fs), "unexpected directive");
2026-08-28 23:24:19 +01:00
// switch_root parses as a SwitchRoot command
auto sr = parse_string("on boot\n switch_root /mnt/root /sbin/init --stage2\n",
fs);
CHECK_EQ(sr.actions.size(), 1u);
CHECK_EQ(sr.actions[0].commands.size(), 1u);
CHECK(sr.actions[0].commands[0].kind == Command::Kind::SwitchRoot);
CHECK_EQ(sr.actions[0].commands[0].args.size(), 3u);
CHECK_EQ(sr.actions[0].commands[0].args[0], std::string("/mnt/root"));
CHECK_EQ(sr.actions[0].commands[0].args[1], std::string("/sbin/init"));
CHECK_EQ(sr.actions[0].commands[0].args[2], std::string("--stage2"));
// `depends = A B ...` is a repeatable, multi-valued service option
auto dp = parse_string(
"service web /usr/sbin/httpd\n"
" depends = net\n"
" depends = db cache\n",
fs);
CHECK_EQ(dp.services.size(), 1u);
CHECK_EQ(dp.services[0].depends.size(), 3u);
CHECK_EQ(dp.services[0].depends[0], std::string("net"));
CHECK_EQ(dp.services[0].depends[1], std::string("db"));
CHECK_EQ(dp.services[0].depends[2], std::string("cache"));
// setprop/getprop action commands and `on property:` triggers
auto pp = parse_string(
"on boot\n"
" setprop net.up 1\n"
" getprop net.up\n"
"on property:net.up=1\n"
" log network is up\n",
fs);
CHECK_EQ(pp.actions.size(), 2u);
// property trigger string is captured verbatim
CHECK_EQ(pp.actions[1].trigger, std::string("property:net.up=1"));
CHECK_EQ(pp.actions[0].commands[0].kind, Command::Kind::Setprop);
CHECK_EQ(pp.actions[0].commands[0].args.size(), 2u);
CHECK_EQ(pp.actions[0].commands[0].args[0], std::string("net.up"));
CHECK_EQ(pp.actions[0].commands[0].args[1], std::string("1"));
CHECK(pp.actions[0].commands[1].kind == Command::Kind::Getprop);
// `logfile = PATH` is an optional service option
auto lf = parse_string(
"service daemon /usr/sbin/daemon\n"
" logfile = /var/log/daemon.log\n",
fs);
CHECK_EQ(lf.services.size(), 1u);
CHECK_EQ(lf.services[0].logfile, std::string("/var/log/daemon.log"));
auto nl = parse_string(
"service plain /usr/sbin/plain\n",
fs);
CHECK_EQ(nl.services[0].logfile, std::string(""));
2026-08-28 00:22:49 +01:00
}
void test_imports() {
VirtualFS fs;
fs.files["/t/lib/base.rc"] = "service base /bin/true\non boot\n log base\n";
fs.files["/t/lib/net.rc"] = "service net /bin/true\n";
// relative + transitive imports resolve against the importing file's dir
auto cfg = parse_string("@import lib/base.rc\n@import lib/net.rc\n", fs);
CHECK_EQ(cfg.services.size(), 2u);
CHECK_EQ(cfg.actions.size(), 1u);
// absolute import
fs.files["/t/abs.rc"] = "service abs /bin/true\n";
cfg = parse_string("@import /t/abs.rc\n", fs);
CHECK_EQ(cfg.services.size(), 1u);
// diamond imports dedupe: the shared file is not replayed
fs.files["/t/a.rc"] = "@import lib/base.rc\n";
fs.files["/t/b.rc"] = "@import lib/base.rc\n";
cfg = parse_string("@import a.rc\n@import b.rc\n", fs);
CHECK_EQ(cfg.services.size(), 1u); // base defined once, not twice
CHECK_EQ(cfg.actions.size(), 1u);
// missing import carries the full ancestry
fs.files["/t/mid.rc"] = "@import /t/nope.rc\n";
CHECK_THROWS(parse_string("@import mid.rc\n", fs),
"cannot open config file: /t/nope.rc (imported from "
"/t/main.rc -> /t/mid.rc)");
// nested errors get "(at file:line)" context chained upward
fs.files["/t/bad.rc"] = "BROKEN = yes\n";
CHECK_THROWS(parse_string("@import bad.rc\n", fs),
"/t/bad.rc:1: unexpected directive 'BROKEN' (at /t/main.rc:1)");
// circular imports
fs.files["/t/x.rc"] = "@import /t/y.rc\n";
fs.files["/t/y.rc"] = "@import /t/x.rc\n";
CHECK_THROWS(parse_string("@import /t/x.rc\n", fs), "circular import");
fs.files["/t/self.rc"] = "@import self.rc\n";
CHECK_THROWS(parse_string("@import self.rc\n", fs), "circular import");
// placement rules
CHECK_THROWS(parse_string("on boot\n log x\n@import lib/base.rc\n", fs),
"only valid at column 0 outside a section");
CHECK_THROWS(parse_string("@import a b\n", fs), "exactly one path");
CHECK_THROWS(parse_string(" @import lib/base.rc\n", fs),
"only valid at column 0");
}
void test_supervisor_helpers() {
// numeric ids pass through; unknown names resolve to -1
CHECK(resolve_user("0") == 0);
CHECK(resolve_user("65534") == 65534u);
CHECK(resolve_user("definitely-not-a-user") == static_cast<uid_t>(-1));
CHECK(resolve_group("1") == 1);
CHECK(resolve_group("garbage-group-name") == static_cast<gid_t>(-1));
// service_changed: equal copies are unchanged, each field flips it
Service a;
a.name = "x";
a.args = {"/bin/true"};
CHECK(!service_changed(a, a));
Service b = a;
b.args = {"/bin/false"};
CHECK(service_changed(a, b));
b = a;
b.cwd = "/tmp";
CHECK(service_changed(a, b));
b = a;
b.uid = "nobody";
CHECK(service_changed(a, b));
b = a;
b.respawn = RespawnPolicy::Never;
CHECK(service_changed(a, b));
b = a;
b.env = {"A=1"};
CHECK(service_changed(a, b));
b = a;
b.service_class = "other";
CHECK(service_changed(a, b));
// status_line renders running/stopped state + flags
Service svc;
svc.name = "web";
svc.service_class = "default";
CHECK_EQ(status_line(svc), std::string("web stopped (last exit 0) class default\n"));
svc.running = true;
svc.pid = 42;
CHECK_EQ(status_line(svc), std::string("web running pid 42 class default\n"));
svc.oneshot = true;
svc.service_class = "tools";
CHECK_EQ(status_line(svc),
std::string("web running pid 42 oneshot class tools\n"));
}
2026-08-28 23:24:19 +01:00
void test_crash_window() {
// a service that crashes repeatedly inside the window gets throttled at
// the threshold, then recovers once the window elapses.
using Clock = std::chrono::steady_clock;
using namespace std::chrono;
Service svc;
svc.name = "boom";
svc.crash_threshold = 2;
svc.crash_window_secs = 10;
auto t = Clock::now();
// first two crashes within the window are allowed
CHECK(record_crash(svc, t) == CrashAction::Respawn);
CHECK_EQ(svc.crash_count, 1);
CHECK(!svc.throttled);
CHECK(record_crash(svc, t + seconds(1)) == CrashAction::Respawn);
CHECK_EQ(svc.crash_count, 2);
CHECK(!svc.throttled);
// third crash crosses the threshold -> throttle
CHECK(record_crash(svc, t + seconds(2)) == CrashAction::Throttle);
CHECK_EQ(svc.crash_count, 3);
CHECK(svc.throttled);
// Once a fresh window opens (the crash was long after the last one), the
// count resets but `throttled` stays set until an explicit start -- the
// reaper stops respawning a throttled service, so the only recovery is a
// manual `start`/`restart` (reset_crash_state below).
CHECK(record_crash(svc, t + seconds(11)) == CrashAction::Respawn);
CHECK_EQ(svc.crash_count, 1);
CHECK(svc.throttled);
// a manual start resets the throttle, opening a fresh window.
reset_crash_state(svc);
CHECK_EQ(svc.crash_count, 0);
CHECK(!svc.throttled);
// threshold=1 means a single crash then throttle.
Service one;
one.crash_threshold = 1;
one.crash_window_secs = 10;
CHECK(record_crash(one, t) == CrashAction::Respawn);
CHECK(record_crash(one, t + seconds(1)) == CrashAction::Throttle);
// a degenerate (unset) window never throttles.
Service none;
none.crash_threshold = 0;
none.crash_window_secs = 0;
for (int i = 0; i < 100; ++i)
CHECK(record_crash(none, t + seconds(i)) == CrashAction::Respawn);
CHECK_EQ(none.crash_count, 0);
CHECK(!none.throttled);
}
void test_dependencies() {
Config cfg;
auto add = [&](const std::string& name, std::initializer_list<const char*> deps) {
Service s;
s.name = name;
for (auto* d : deps)
s.depends.emplace_back(d);
cfg.services.push_back(std::move(s));
};
add("a", {});
add("b", {"a"});
add("c", {"b"});
add("d", {"b", "c"}); // depends on a chain + a sibling
add("e", {});
// linear chain: c -> b -> a
std::vector<std::string> order;
CHECK(resolve_dependencies(cfg, "c", order) == DepResolve::Ok);
CHECK_EQ(order.size(), 3u);
CHECK(order[0] == "a");
CHECK(order[1] == "b");
CHECK(order[2] == "c");
// declared order is preserved; shared deps appear once (diamond / DAG)
order.clear();
CHECK(resolve_dependencies(cfg, "d", order) == DepResolve::Ok);
CHECK_EQ(order.size(), 4u);
CHECK(order[0] == "a"); // b's dep
CHECK(order[1] == "b"); // first declared dep of d
CHECK(order[2] == "c"); // second declared dep of d (and c->b already done)
CHECK(order[3] == "d");
// a service with no deps yields just itself
order.clear();
CHECK(resolve_dependencies(cfg, "e", order) == DepResolve::Ok);
CHECK_EQ(order.size(), 1u);
CHECK(order[0] == "e");
// unknown dependency
Service ghost;
ghost.name = "ghost";
ghost.depends.push_back("nope");
cfg.services.push_back(std::move(ghost));
order.clear();
CHECK(resolve_dependencies(cfg, "ghost", order) == DepResolve::Unknown);
// self-cycle
Service self;
self.name = "self";
self.depends.push_back("self");
cfg.services.push_back(std::move(self));
order.clear();
CHECK(resolve_dependencies(cfg, "self", order) == DepResolve::Cycle);
// mutual cycle a<->b
Service m1, m2;
m1.name = "m1";
m1.depends.push_back("m2");
m2.name = "m2";
m2.depends.push_back("m1");
cfg.services.push_back(std::move(m1));
cfg.services.push_back(std::move(m2));
order.clear();
CHECK(resolve_dependencies(cfg, "m1", order) == DepResolve::Cycle);
}
2026-08-28 00:22:49 +01:00
} // namespace
int main() {
test_tokenize();
test_parse_respawn();
test_resolve_import_path();
test_parse_basic();
test_imports();
test_supervisor_helpers();
2026-08-28 23:24:19 +01:00
test_crash_window();
test_dependencies();
2026-08-28 00:22:49 +01:00
std::printf("%d checks, %d failures\n", g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}