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>
This commit is contained in:
parent
561398013c
commit
8d031a1dba
3 changed files with 271 additions and 0 deletions
|
|
@ -152,6 +152,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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() });
|
||||
}
|
||||
|
|
|
|||
30
tests/Script/fixture/project.cpp
Normal file
30
tests/Script/fixture/project.cpp
Normal file
|
|
@ -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<const std::string_view> 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;
|
||||
}
|
||||
239
tests/Script/main.cpp
Normal file
239
tests/Script/main.cpp
Normal file
|
|
@ -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 <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");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Loading…
Reference in a new issue