From 8d031a1dba20f682181d47f1ef123b7834d01f8f Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 18:16:00 +0200 Subject: [PATCH] test(script): names, arguments and exit codes, in process and by CLI RunScript on hand-built configurations: the named script alone runs, with the project, the project directory and the arguments exactly as given; its return value comes back whole from 0 to 255 and as 1 outside that; a script that throws exits 1. A project without scripts, a duplicate, an untypeable name, a missing callback and an unknown name all fail without running anything. ctx.Run returns the command's exit code and 128+signal for a killed one, and ShellQuote carries quotes, spaces and expansions through sh intact. Then the same through Run() with a fixture project, because the parsing is where this goes wrong. `run record a --help -v clean --debug --version run` must hand all seven words to the script, leave the one project arg that stood before `run` alone, and not delete the staged bin/ and build/. `run clean` and `run help` look for scripts of those names. Moving the two capture branches below the verbs in the parse loop fails this test. The command-line half needs the installed module sources and is skipped without them, but only when the in-process half reported no failure. Co-Authored-By: Claude Fable 5.1 --- project.cpp | 2 + tests/Script/fixture/project.cpp | 30 ++++ tests/Script/main.cpp | 239 +++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 tests/Script/fixture/project.cpp create mode 100644 tests/Script/main.cpp diff --git a/project.cpp b/project.cpp index 73cf506..a281e35 100644 --- a/project.cpp +++ b/project.cpp @@ -152,6 +152,8 @@ extern "C" Configuration CrafterBuildProject(std::span a .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ProjectDependency").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); + cfg.AddTest("Script").Dependencies({ CrafterBuildLib.get() }) + .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); } diff --git a/tests/Script/fixture/project.cpp b/tests/Script/fixture/project.cpp new file mode 100644 index 0000000..1763e24 --- /dev/null +++ b/tests/Script/fixture/project.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// Scripts that report what they were handed. The test that loads this file +// drives the real command line and can only observe a script through its +// exit code and through what it leaves in the project directory. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "script-fixture"; + cfg.outputName = "script-fixture"; + // Applied so that a script argument which leaked into the project's + // args would be visible: in cfg.projectArgs, or as cfg.debug. + ApplyStandardArgs(cfg, args); + + cfg.scripts.push_back({"record", [](ScriptContext& ctx) { + std::ofstream out(ctx.projectDir / "record.txt"); + for (const std::string& arg : ctx.args) std::println(out, "arg:{}", arg); + for (const std::string& arg : ctx.project.projectArgs) std::println(out, "projectArg:{}", arg); + std::println(out, "debug:{}", ctx.project.debug); + std::println(out, "project:{}", ctx.project.name); + return 42; + }}); + cfg.scripts.push_back({"shell", [](ScriptContext& ctx) { return ctx.Run("exit 7"); }}); + return cfg; +} diff --git a/tests/Script/main.cpp b/tests/Script/main.cpp new file mode 100644 index 0000000..89e83fb --- /dev/null +++ b/tests/Script/main.cpp @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// `crafter-build run [args...]` runs a script project.cpp put in +// cfg.scripts and exits with what the script returned. +// +// Two things are easy to get wrong and expensive when they are. The words +// after the script's name belong to the script: read by crafter-build +// instead, `run package clean` deletes the build tree, `run package --help` +// prints the wrong help, and anything else lands in the project's args, +// where it changes the variant hash and so the directory the project builds +// into. And the exit code is the script's whole result: collapsed to 0 or 1 +// it loses what a caller branches on, and passed through unchecked a value +// such as 256 wraps to success. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + std::int32_t RunWith(std::vector args) { + std::vector argv; + for (std::string& arg : args) argv.push_back(arg.data()); + return Run(static_cast(argv.size()), argv.data()); + } + + std::vector ReadLines(const fs::path& file) { + std::vector lines; + std::ifstream in(file); + for (std::string line; std::getline(in, line);) lines.push_back(line); + return lines; + } + + // One script that returns `code`, run by name. + std::int32_t RunReturning(std::int32_t code) { + Configuration cfg; + cfg.scripts.push_back({"it", [code](ScriptContext&) { return code; }}); + return RunScript(cfg, {.name = "it", .named = true}); + } + + // LoadProject compiles a project file against the installed module + // sources; without them the command-line cases cannot run at all. + bool ModuleSourcesInstalled() { + try { + return fs::exists(GetCrafterBuildHome() / "Crafter.Build.cppm"); + } catch (const std::exception&) { + return false; + } + } +} + +int main() { + const fs::path fixture = fs::current_path() / "tests" / "Script" / "fixture"; + const fs::path scratch = fs::temp_directory_path() / "crafter-build-script"; + std::error_code ec; + fs::remove_all(scratch, ec); + fs::create_directories(scratch); + fs::copy(fixture, scratch, fs::copy_options::recursive); + + // The script gets the project, the project's directory and its + // arguments exactly as given, and its return value comes back. + { + Configuration cfg; + std::int32_t calls = 0; + std::vector seen; + const Configuration* seenProject = nullptr; + fs::path seenDir; + cfg.scripts.push_back({"other", [&](ScriptContext&) { + ++calls; + return 9; + }}); + cfg.scripts.push_back({"pass", [&](ScriptContext& ctx) { + ++calls; + seen.assign(ctx.args.begin(), ctx.args.end()); + seenProject = &ctx.project; + seenDir = ctx.projectDir; + return 3; + }}); + const std::vector given = {"a", "--help", "", "two words", "it's"}; + const std::int32_t code = RunScript(cfg, {.name = "pass", .named = true, .args = given, .projectFile = scratch / "project.cpp"}); + Check(code == 3, std::format("the script's return value is the result (got {})", code)); + Check(calls == 1, std::format("only the named script ran (ran {})", calls)); + Check(seen == given, "the arguments arrive verbatim, empty ones and option-like ones included"); + Check(seenProject == &cfg, "ctx.project is the configuration the script was declared on"); + Check(seenDir == scratch, std::format("ctx.projectDir is the project file's directory (got '{}')", seenDir.string())); + + // Asking for the list runs nothing, with or without a name. + calls = 0; + Check(RunScript(cfg, {}) == 0, "a run without a name lists and succeeds"); + Check(RunScript(cfg, {.name = "pass", .named = true, .listOnly = true}) == 0, "--list succeeds"); + Check(calls == 0, "listing runs no script"); + + // A name nobody declared. The empty string is one: it is a name + // that was given, not the absence of a name. + Check(RunScript(cfg, {.name = "nope", .named = true}) == 1, "an unknown name fails"); + Check(RunScript(cfg, {.name = "", .named = true}) == 1, "an empty name is looked up and fails instead of listing"); + Check(RunScript(cfg, {.name = "pas", .named = true}) == 1, "names match exactly, not by prefix"); + Check(calls == 0, "an unknown name runs no script"); + } + + // The exit status passes through whole, and only an exit status does. + Check(RunReturning(0) == 0, "0 stays 0"); + Check(RunReturning(1) == 1, "1 stays 1"); + Check(RunReturning(77) == 77, "77 stays 77"); + Check(RunReturning(255) == 255, "255 stays 255"); + Check(RunReturning(256) == 1, "256 would wrap to success as an exit status and becomes 1"); + Check(RunReturning(-1) == 1, "a negative value becomes 1"); + + // A script that throws is that script's failure, not a crash. + { + Configuration cfg; + cfg.scripts.push_back({"throws", [](ScriptContext&) -> std::int32_t { throw std::runtime_error("no such tool"); }}); + Check(RunScript(cfg, {.name = "throws", .named = true}) == 1, "a throwing script exits 1"); + } + + // Declarations that cannot work are refused before anything runs, also + // when a different, valid script was asked for. + { + Configuration none; + Check(RunScript(none, {.name = "it", .named = true}) == 1, "a project without scripts fails"); + Check(RunScript(none, {}) == 1, "listing a project without scripts fails too"); + + std::int32_t calls = 0; + const auto count = [&](ScriptContext&) { + ++calls; + return 0; + }; + Configuration twice; + twice.scripts.push_back({"a", count}); + twice.scripts.push_back({"b", count}); + twice.scripts.push_back({"a", count}); + Check(RunScript(twice, {.name = "b", .named = true}) == 1, "a name declared twice fails the run"); + Check(RunScript(twice, {}) == 1, "a name declared twice fails the list"); + + Configuration dashed; + dashed.scripts.push_back({"ok", count}); + dashed.scripts.push_back({"-x", count}); + Check(RunScript(dashed, {.name = "ok", .named = true}) == 1, "a name starting with '-' could never be typed and is refused"); + + Configuration unnamed; + unnamed.scripts.push_back({"", count}); + Check(RunScript(unnamed, {.name = "", .named = true}) == 1, "an empty name is refused"); + + Configuration hollow; + hollow.scripts.push_back({"hollow", nullptr}); + Check(RunScript(hollow, {.name = "hollow", .named = true}) == 1, "a script without a callback is refused"); + Check(calls == 0, "none of these ran a script"); + } + + // ctx.Run: the command's real exit code, a signal death the way a shell + // reports it, and ShellQuote carrying awkward text through sh intact. + { + const std::string awkward = "it's a \"test\" $HOME `id` \\ *"; + const fs::path echoed = scratch / "echoed file.txt"; + Configuration cfg; + cfg.scripts.push_back({"exit", [](ScriptContext& ctx) { return ctx.Run("exit 7"); }}); + cfg.scripts.push_back({"signal", [](ScriptContext& ctx) { return ctx.Run("kill -TERM $$"); }}); + cfg.scripts.push_back({"quote", [&](ScriptContext& ctx) { return ctx.Run(std::format("printf %s {} > {}", ShellQuote(awkward), ShellQuote(echoed.string()))); }}); + // A script is usually several commands: stop at the first failure. + cfg.scripts.push_back({"steps", [&](ScriptContext& ctx) { + if (const std::int32_t code = ctx.Run("exit 5"); code != 0) return code; + return ctx.Run(std::format("touch {}", ShellQuote((scratch / "not-reached").string()))); + }}); + Check(RunScript(cfg, {.name = "exit", .named = true}) == 7, "ctx.Run returns the command's exit code"); + Check(RunScript(cfg, {.name = "signal", .named = true}) == 143, "a command killed by SIGTERM reads as 128+15"); + Check(RunScript(cfg, {.name = "quote", .named = true}) == 0, "a quoted command line runs"); + const std::vector lines = ReadLines(echoed); + Check(lines.size() == 1 && lines[0] == awkward, "ShellQuote passes quotes, spaces and expansions through sh untouched"); + Check(RunScript(cfg, {.name = "steps", .named = true}) == 5, "a failed step's code can be returned as the script's"); + Check(!fs::exists(scratch / "not-reached"), "and the steps after it do not run"); + } + + if (!ModuleSourcesInstalled()) { + // A skip must never cover for a failure above. + if (Failures > 0) return 1; + std::println(std::cerr, "SKIP: no module sources to load the fixture project with"); + return 77; + } + + // The same through the command line, where the parsing lives. Sentinels + // stand in for a built project: a word after `run` that is read as the + // clean verb takes them with it. + const std::string project = std::format("--project={}", (scratch / "project.cpp").string()); + const fs::path binSentinel = scratch / "bin" / "sentinel"; + const fs::path buildSentinel = scratch / "build" / "sentinel"; + fs::create_directories(binSentinel.parent_path()); + fs::create_directories(buildSentinel.parent_path()); + std::ofstream(binSentinel) << "binary"; + std::ofstream(buildSentinel) << "object"; + + // Everything after the name is the script's, including this tool's own + // options and verbs; what stands before `run` is still the project's. + { + const std::int32_t code = RunWith({"crafter-build", project, "--flavour=x", "run", "record", "a", "--help", "-v", "clean", "--debug", "--version", "run"}); + Check(code == 42, std::format("the script ran and its return value is the exit code (got {})", code)); + const std::vector expected = { + "arg:a", "arg:--help", "arg:-v", "arg:clean", "arg:--debug", "arg:--version", "arg:run", + "projectArg:--flavour=x", + "debug:false", + "project:script-fixture", + }; + const std::vector record = ReadLines(scratch / "record.txt"); + Check(record == expected, "the script got every word after its name and the project args got none of them"); + if (record != expected) { + for (const std::string& line : record) std::println(std::cerr, " recorded: {}", line); + } + Check(fs::exists(binSentinel) && fs::exists(buildSentinel), "`clean` as a script argument deleted nothing"); + } + + // The first word after `run` is the name even when it is a verb. + { + fs::remove(scratch / "record.txt"); + Check(RunWith({"crafter-build", project, "run", "clean"}) == 1, "`run clean` looks for a script called clean"); + Check(fs::exists(binSentinel) && fs::exists(buildSentinel), "`run clean` deleted nothing"); + Check(RunWith({"crafter-build", project, "run", "help"}) == 1, "`run help` looks for a script called help"); + } + + Check(RunWith({"crafter-build", project, "run", "shell"}) == 7, "a command's exit code reaches the process exit code"); + Check(RunWith({"crafter-build", project, "run", "-q", "shell"}) == 7, "an option between `run` and the name is still this tool's"); + Check(RunWith({"crafter-build", project, "run"}) == 0, "a bare `run` lists"); + Check(RunWith({"crafter-build", project, "run", "--list"}) == 0, "`run --list` lists"); + Check(!fs::exists(scratch / "record.txt"), "listing ran no script"); + Check(RunWith({"crafter-build", project, "run", "nope"}) == 1, "an unknown script fails"); + + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); + return 1; + } + return 0; +}