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 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
|
|
|
// 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 <name> [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<std::string> args) {
|
|
|
|
|
std::vector<char*> argv;
|
|
|
|
|
for (std::string& arg : args) argv.push_back(arg.data());
|
|
|
|
|
return Run(static_cast<std::int32_t>(argv.size()), argv.data());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<std::string> ReadLines(const fs::path& file) {
|
|
|
|
|
std::vector<std::string> 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<std::string> 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<std::string> 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");
|
2026-10-20 19:08:12 +02:00
|
|
|
cfg.scripts.push_back({"throwsInt", [](ScriptContext&) -> std::int32_t { throw 7; }});
|
|
|
|
|
Check(RunScript(cfg, {.name = "throwsInt", .named = true}) == 1, "and so does one throwing something that isn't a std::exception");
|
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 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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<std::string> 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<std::string> 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<std::string> 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;
|
|
|
|
|
}
|