From 0242c0aea1f3bdf17c928e5e325e393dd0045886 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:58:05 -0400 Subject: [PATCH 01/11] feat(test)!: support integration tests --- .github/actions/build-and-test/action.yml | 21 +-- CONTRIBUTING.md | 35 +--- Makefile | 6 + cabin.toml | 3 + src/BuildConfig.cc | 141 +++++++++++---- src/BuildConfig.hpp | 20 ++- src/Builder/Project.cc | 4 +- src/Builder/Project.hpp | 1 + src/Cmd/Test.cc | 47 ++--- tests/.gitignore | 4 - tests/01-cabin-exists.sh | 12 -- tests/02-new.sh | 61 ------- tests/03-fmt.sh | 110 ------------ tests/04-init.sh | 40 ----- tests/05-version.sh | 26 --- tests/06-run.sh | 37 ---- tests/07-remove.sh | 26 --- tests/08-test.sh | 174 ------------------ tests/09-build-ninja.sh | 27 --- tests/build.cc | 26 +++ tests/cabin_exists.cc | 15 ++ tests/fmt.cc | 122 +++++++++++++ tests/helpers.hpp | 194 ++++++++++++++++++++ tests/init.cc | 50 ++++++ tests/new.cc | 83 +++++++++ tests/remove.cc | 46 +++++ tests/run.cc | 40 +++++ tests/setup.sh | 10 -- tests/test.cc | 210 ++++++++++++++++++++++ tests/version.cc | 50 ++++++ 30 files changed, 1020 insertions(+), 621 deletions(-) delete mode 100644 tests/.gitignore delete mode 100644 tests/01-cabin-exists.sh delete mode 100644 tests/02-new.sh delete mode 100644 tests/03-fmt.sh delete mode 100644 tests/04-init.sh delete mode 100644 tests/05-version.sh delete mode 100644 tests/06-run.sh delete mode 100644 tests/07-remove.sh delete mode 100644 tests/08-test.sh delete mode 100755 tests/09-build-ninja.sh create mode 100644 tests/build.cc create mode 100644 tests/cabin_exists.cc create mode 100644 tests/fmt.cc create mode 100644 tests/helpers.hpp create mode 100644 tests/init.cc create mode 100644 tests/new.cc create mode 100644 tests/remove.cc create mode 100644 tests/run.cc delete mode 100644 tests/setup.sh create mode 100644 tests/test.cc create mode 100644 tests/version.cc diff --git a/.github/actions/build-and-test/action.yml b/.github/actions/build-and-test/action.yml index 3a0e77955..e518195e6 100644 --- a/.github/actions/build-and-test/action.yml +++ b/.github/actions/build-and-test/action.yml @@ -23,7 +23,7 @@ runs: shell: bash run: make versions - - name: Stage 1 - Build + - name: Stage 0 - Build Stage 1 shell: bash run: make BUILD=${{ inputs.build }} -j4 @@ -31,27 +31,28 @@ runs: shell: bash run: ./build/cabin version --verbose - - name: Stage 1 - Integration Test + - name: Stage 1 - Test shell: bash - run: find tests -maxdepth 1 -name '[0-9]*.sh' -print0 | xargs -0 -I {} sh -c 'sh {} -v' + run: ./build/cabin test -vv env: - CABIN_TERM_COLOR: auto + CABIN: ${{ github.workspace }}/build/cabin - - name: Stage 2 - Build & Test + - name: Stage 1 - Build Stage 2 shell: bash run: | [[ '${{ inputs.build }}' == 'release' ]] && RELEASE='--release' - [[ '${{ inputs.coverage }}' == 'true' ]] && COVERAGE='--coverage' # shellcheck disable=SC2086 - ./build/cabin -vv run $RELEASE test -vv $COVERAGE + ./build/cabin -vv build $RELEASE - name: Stage 2 - Print version shell: bash run: ./cabin-out/${{ inputs.build }}/cabin version --verbose - - name: Stage 2 - Integration Test + - name: Stage 2 - Test shell: bash - run: find tests -maxdepth 1 -name '[0-9]*.sh' -print0 | xargs -0 -I {} sh -c 'sh {} -v' + run: | + [[ '${{ inputs.coverage }}' == 'true' ]] && COVERAGE='--coverage' + # shellcheck disable=SC2086 + ./cabin-out/${{ inputs.build }}/cabin test -vv $COVERAGE env: CABIN: ${{ github.workspace }}/cabin-out/${{ inputs.build }}/cabin - CABIN_TERM_COLOR: auto diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76cb8dac1..bea883789 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,42 +56,25 @@ Be mindful of when to use structs vs. classes. For guidance, refer to the ### Formatting and Linting Before submitting a PR, ensure your code adheres to the project's coding -standards by running the following tools: +standards. Also, always validate your changes to ensure they do not +introduce regressions or break existing functionality: 1. Run the linter (`cpplint`) ```bash - cabin lint + ./build/cabin lint ``` 2. Run the formatter (`clang-format`) ```bash - cabin fmt + ./build/cabin fmt ``` 3. Run the static analyzer (`clang-tidy`) ```bash - cabin tidy + ./build/cabin tidy + ``` +4. Run tests + ```bash + ./build/cabin test ``` - -### Testing - -Always validate your changes to ensure they do not introduce regressions or -break existing functionality: - -```bash -# Unit tests -cabin test - -# Integration tests -wget https://raw.githubusercontent.com/felipec/sharness/refs/tags/v1.2.1/sharness.sh -wget https://raw.githubusercontent.com/felipec/sharness/refs/tags/v1.2.1/lib-sharness/functions.sh -mv sharness.sh tests/ -mkdir tests/lib-sharness -mv functions.sh tests/lib-sharness/ -prove -j$(nproc) --shuffle tests/[0-9]*.sh -``` - -Make sure to add new tests for any new functionality you introduce. See - for more information on -how to use `sharness`. ### Packaging diff --git a/Makefile b/Makefile index d7118624a..c342a60ee 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ DEFINES := -DCABIN_CABIN_PKG_VERSION='"$(VERSION)"' \ -DCABIN_CABIN_COMMIT_DATE='"$(COMMIT_DATE)"' INCLUDES := -Isrc -isystem $(O)/DEPS/toml11/include \ -isystem $(O)/DEPS/mitama-cpp-result/include \ + -isystem $(O)/DEPS/boost-ut/include \ $(shell pkg-config --cflags '$(LIBGIT2_VERREQ)') \ $(shell pkg-config --cflags '$(LIBCURL_VERREQ)') \ $(shell pkg-config --cflags '$(NLOHMANN_JSON_VERREQ)') \ @@ -105,3 +106,8 @@ $(O)/DEPS/mitama-cpp-result: $(MKDIR_P) $(@D) $(GIT) clone https://github.com/loliGothicK/mitama-cpp-result.git $@ $(GIT) -C $@ reset --hard $(RESULT_VER) + +$(O)/DEPS/boost-ut: + $(MKDIR_P) $(@D) + $(GIT) clone https://github.com/boost-ext/ut.git $@ + $(GIT) -C $@ reset --hard $(RESULT_VER) diff --git a/cabin.toml b/cabin.toml index bd6a7ad14..a0e32b723 100644 --- a/cabin.toml +++ b/cabin.toml @@ -20,6 +20,9 @@ libgit2 = {version = ">=1.7 && <1.10", system = true} nlohmann_json = {version = "3.10.5", system = true} tbb = {version = ">=2021.5.0 && <2023.0.0", system = true} +[dev-dependencies] +boost-ut = {git = "https://github.com/boost-ext/ut.git", tag = "v2.3.1"} + [profile] cxxflags = ["-pedantic-errors", "-Wall", "-Wextra", "-Wpedantic", "-fno-rtti"] diff --git a/src/BuildConfig.cc b/src/BuildConfig.cc index 5366df060..0b90dcb8a 100644 --- a/src/BuildConfig.cc +++ b/src/BuildConfig.cc @@ -245,7 +245,12 @@ void BuildConfig::writeTargetsNinja() const { << '\n'; } if (!testTargets.empty()) { - targetsFile << "build tests: phony " << joinFlags(testTargets) << '\n' + std::vector testTargetNames; + testTargetNames.reserve(testTargets.size()); + for (const TestTarget& target : testTargets) { + testTargetNames.push_back(target.ninjaTarget); + } + targetsFile << "build tests: phony " << joinFlags(testTargetNames) << '\n' << '\n'; } } @@ -344,12 +349,12 @@ BuildConfig::processSources(const std::vector& sourceFilePaths) { return Ok(buildObjTargets); } -Result BuildConfig::processUnittestSrc( +Result> BuildConfig::processUnittestSrc( const fs::path& sourceFilePath, const std::unordered_set& buildObjTargets, - std::unordered_set& testBinaryTargets, tbb::spin_mutex* mtx) { + tbb::spin_mutex* mtx) { if (!Try(containsTestCode(sourceFilePath))) { - return Ok(); + return Ok(std::optional()); } std::string objTarget; @@ -390,11 +395,81 @@ Result BuildConfig::processUnittestSrc( registerCompileUnit(testObjTarget, sourceFilePath.string(), objTargetDeps, /*isTest=*/true); addEdge(std::move(linkEdge)); - testBinaryTargets.insert(testBinary); if (mtx) { mtx->unlock(); } - return Ok(); + + TestTarget testTarget; + testTarget.ninjaTarget = testBinary; + testTarget.sourcePath = + fs::relative(sourceFilePath, project.rootPath).generic_string(); + testTarget.kind = TestKind::Unit; + + return Ok(std::optional(std::move(testTarget))); +} + +Result> +BuildConfig::processIntegrationTestSrc( + const fs::path& sourceFilePath, + const std::unordered_set& buildObjTargets, + tbb::spin_mutex* mtx) { + std::string objTarget; + const std::unordered_set objTargetDeps = + parseMMOutput(Try(runMM(sourceFilePath, /*isTest=*/true)), objTarget); + + const fs::path targetBaseDir = + fs::relative(sourceFilePath.parent_path(), project.rootPath / "tests"); + fs::path testTargetBaseDir = project.integrationTestOutPath; + if (targetBaseDir != ".") { + testTargetBaseDir /= targetBaseDir; + } + + const fs::path testObjOutput = testTargetBaseDir / objTarget; + const std::string testObjTarget = + fs::relative(testObjOutput, outBasePath).generic_string(); + const fs::path testBinaryPath = testTargetBaseDir / sourceFilePath.stem(); + const std::string testBinary = + fs::relative(testBinaryPath, outBasePath).generic_string(); + + std::unordered_set deps = { testObjTarget }; + collectBinDepObjs(deps, sourceFilePath.stem().string(), objTargetDeps, + buildObjTargets); + for (const std::string& obj : buildObjTargets) { + if (obj.ends_with("/main.o") || obj == "main.o") { + continue; + } + deps.insert(obj); + } + if (hasLibraryTarget) { + deps.insert(libName); + } + + std::vector linkInputs(deps.begin(), deps.end()); + std::ranges::sort(linkInputs); + + NinjaEdge linkEdge; + linkEdge.outputs = { testBinary }; + linkEdge.rule = "cxx_link"; + linkEdge.inputs = std::move(linkInputs); + linkEdge.bindings.emplace_back("out_dir", parentDirOrDot(testBinary)); + + if (mtx) { + mtx->lock(); + } + registerCompileUnit(testObjTarget, sourceFilePath.string(), objTargetDeps, + /*isTest=*/true); + addEdge(std::move(linkEdge)); + if (mtx) { + mtx->unlock(); + } + + TestTarget testTarget; + testTarget.ninjaTarget = testBinary; + testTarget.sourcePath = + fs::relative(sourceFilePath, project.rootPath).generic_string(); + testTarget.kind = TestKind::Integration; + + return Ok(std::optional(std::move(testTarget))); } void BuildConfig::collectBinDepObjs( @@ -578,34 +653,38 @@ Result BuildConfig::configureBuild() { defaultTargets.push_back(libName); } - std::unordered_set testBinaryTargets; - if (isParallel()) { - tbb::concurrent_vector results; - tbb::spin_mutex mtx; - tbb::parallel_for( - tbb::blocked_range(0, sourceFilePaths.size()), - [&](const tbb::blocked_range& rng) { - for (std::size_t i = rng.begin(); i != rng.end(); ++i) { - std::ignore = - processUnittestSrc(sourceFilePaths[i], buildObjTargets, - testBinaryTargets, &mtx) - .map_err([&results](const auto& err) { - results.push_back(err->what()); - }); - } - }); - if (!results.empty()) { - Bail("{}", fmt::join(results, "\n")); - } - } else { + if (buildProfile == BuildProfile::Test) { + std::vector discoveredTests; + discoveredTests.reserve(sourceFilePaths.size()); for (const fs::path& sourceFilePath : sourceFilePaths) { - Try(processUnittestSrc(sourceFilePath, buildObjTargets, - testBinaryTargets)); + if (auto maybeTarget = + Try(processUnittestSrc(sourceFilePath, buildObjTargets)); + maybeTarget.has_value()) { + discoveredTests.push_back(std::move(maybeTarget.value())); + } } - } - testTargets.assign(testBinaryTargets.begin(), testBinaryTargets.end()); - std::ranges::sort(testTargets); + const fs::path integrationTestDir = project.rootPath / "tests"; + if (fs::exists(integrationTestDir)) { + std::vector integrationSources = + listSourceFilePaths(integrationTestDir); + for (const fs::path& sourceFilePath : integrationSources) { + if (auto maybeTarget = + Try(processIntegrationTestSrc(sourceFilePath, buildObjTargets)); + maybeTarget.has_value()) { + discoveredTests.push_back(std::move(maybeTarget.value())); + } + } + } + + std::ranges::sort(discoveredTests, + [](const TestTarget& lhs, const TestTarget& rhs) { + return lhs.ninjaTarget < rhs.ninjaTarget; + }); + testTargets = std::move(discoveredTests); + } else { + testTargets.clear(); + } return Ok(); } diff --git a/src/BuildConfig.hpp b/src/BuildConfig.hpp index f363a0a2c..56651f12b 100644 --- a/src/BuildConfig.hpp +++ b/src/BuildConfig.hpp @@ -29,6 +29,7 @@ inline const std::unordered_set HEADER_FILE_EXTS{ class BuildConfig { public: + struct TestTarget; // NOLINTNEXTLINE(*-non-private-member-variables-in-classes) fs::path outBasePath; @@ -59,7 +60,7 @@ class BuildConfig { std::unordered_map compileUnits; std::vector ninjaEdges; std::vector defaultTargets; - std::vector testTargets; + std::vector testTargets; std::string cxxFlags; std::string defines; @@ -87,6 +88,14 @@ class BuildConfig { libName(std::move(libName)) {} public: + enum class TestKind : std::uint8_t { Unit, Integration }; + + struct TestTarget { + std::string ninjaTarget; + std::string sourcePath; + TestKind kind = TestKind::Unit; + }; + static Result init(const Manifest& manifest, const BuildProfile& buildProfile = BuildProfile::Dev); @@ -98,7 +107,7 @@ class BuildConfig { bool ninjaIsUpToDate() const { return isUpToDate("build.ninja"); } bool compdbIsUpToDate() const { return isUpToDate("compile_commands.json"); } - const std::vector& getTestTargets() const { return testTargets; } + const std::vector& getTestTargets() const { return testTargets; } Result installDeps(bool includeDevDeps); void enableCoverage(); @@ -109,11 +118,14 @@ class BuildConfig { Result> processSources(const std::vector& sourceFilePaths); - Result + Result> processUnittestSrc(const fs::path& sourceFilePath, const std::unordered_set& buildObjTargets, - std::unordered_set& testBinaryTargets, tbb::spin_mutex* mtx = nullptr); + Result> processIntegrationTestSrc( + const fs::path& sourceFilePath, + const std::unordered_set& buildObjTargets, + tbb::spin_mutex* mtx = nullptr); void collectBinDepObjs( // NOLINT(misc-no-recursion) std::unordered_set& deps, std::string_view sourceFileName, diff --git a/src/Builder/Project.cc b/src/Builder/Project.cc index c15a53eef..5324a9401 100644 --- a/src/Builder/Project.cc +++ b/src/Builder/Project.cc @@ -80,11 +80,13 @@ Project::Project(const BuildProfile& buildProfile, Manifest m, : rootPath(m.path.parent_path()), outBasePath(rootPath / "cabin-out" / fmt::format("{}", buildProfile)), buildOutPath(outBasePath / (m.package.name + ".d")), - unittestOutPath(outBasePath / "unittests"), manifest(std::move(m)), + unittestOutPath(outBasePath / "unit"), + integrationTestOutPath(outBasePath / "intg"), manifest(std::move(m)), compilerOpts(std::move(opts)) // { includeIfExist(rootPath / "src", /*isSystem=*/false); includeIfExist(rootPath / "include", /*isSystem=*/false); + includeIfExist(rootPath / "tests", /*isSystem=*/false); compilerOpts.cFlags.others.emplace_back("-std=c++" + manifest.package.edition.str); diff --git a/src/Builder/Project.hpp b/src/Builder/Project.hpp index 3eadfb793..cf0b0ca3f 100644 --- a/src/Builder/Project.hpp +++ b/src/Builder/Project.hpp @@ -22,6 +22,7 @@ class Project { const fs::path outBasePath; const fs::path buildOutPath; const fs::path unittestOutPath; + const fs::path integrationTestOutPath; const Manifest manifest; CompilerOpts compilerOpts; diff --git a/src/Cmd/Test.cc b/src/Cmd/Test.cc index a7fa65433..30b6c6270 100644 --- a/src/Cmd/Test.cc +++ b/src/Cmd/Test.cc @@ -27,7 +27,7 @@ namespace cabin { class Test { Manifest manifest; fs::path outDir; - std::vector unittestTargets; + std::vector collectedTargets; bool enableCoverage = false; explicit Test(Manifest manifest) : manifest(std::move(manifest)) {} @@ -54,16 +54,22 @@ Result Test::compileTestTargets() { const BuildConfig config = Try(emitNinja( manifest, buildProfile, /*includeDevDeps=*/true, enableCoverage)); outDir = config.outBasePath; - unittestTargets = config.getTestTargets(); + collectedTargets = config.getTestTargets(); - if (unittestTargets.empty()) { + if (collectedTargets.empty()) { Diag::warn("No test targets found"); return Ok(); } + std::vector ninjaTargets; + ninjaTargets.reserve(collectedTargets.size()); + for (const BuildConfig::TestTarget& target : collectedTargets) { + ninjaTargets.push_back(target.ninjaTarget); + } + Command baseCmd = getNinjaCommand(); baseCmd.addArg("-C").addArg(outDir.string()); - const bool needsBuild = Try(ninjaNeedsWork(outDir, unittestTargets)); + const bool needsBuild = Try(ninjaNeedsWork(outDir, ninjaTargets)); if (needsBuild) { Diag::info("Compiling", "{} v{} ({})", manifest.package.name, @@ -71,8 +77,8 @@ Result Test::compileTestTargets() { manifest.path.parent_path().string()); Command buildCmd(baseCmd); - for (const std::string& target : unittestTargets) { - buildCmd.addArg(target); + for (const std::string& targetName : ninjaTargets) { + buildCmd.addArg(targetName); } const ExitStatus exitStatus = Try(execCmd(buildCmd)); @@ -90,30 +96,27 @@ Result Test::compileTestTargets() { } Result Test::runTestTargets() { - using std::string_view_literals::operator""sv; - const auto start = std::chrono::steady_clock::now(); std::size_t numPassed = 0; std::size_t numFailed = 0; ExitStatus exitStatus; - for (const std::string& target : unittestTargets) { - static constexpr std::string_view unitPrefix = "unittests/"; - std::string sourcePath; - if (target.starts_with(unitPrefix)) { - sourcePath = target.substr(unitPrefix.size()); - } else { - sourcePath = target; - } - if (sourcePath.ends_with(".test"sv)) { - sourcePath.resize(sourcePath.size() - ".test"sv.size()); + const auto labelFor = [](BuildConfig::TestKind kind) { + switch (kind) { + case BuildConfig::TestKind::Integration: + return std::string_view("integration"); + case BuildConfig::TestKind::Unit: + default: + return std::string_view("unit"); } - sourcePath.insert(0, "src/"); + }; - const fs::path absoluteBinary = outDir / target; + for (const BuildConfig::TestTarget& target : collectedTargets) { + const fs::path absoluteBinary = outDir / target.ninjaTarget; const std::string testBinPath = fs::relative(absoluteBinary, manifest.path.parent_path()).string(); - Diag::info("Running", "unittests {} ({})", sourcePath, testBinPath); + Diag::info("Running", "{} test {} ({})", labelFor(target.kind), + target.sourcePath, testBinPath); const ExitStatus curExitStatus = Try(execCmd(Command(absoluteBinary.string()))); @@ -173,7 +176,7 @@ Result Test::exec(const CliArgsView cliArgs) { cmd.enableCoverage = enableCoverage; Try(cmd.compileTestTargets()); - if (cmd.unittestTargets.empty()) { + if (cmd.collectedTargets.empty()) { return Ok(); } diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index 92c474764..000000000 --- a/tests/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -lib-sharness/ -test-results/ -sharness.sh -trash* diff --git a/tests/01-cabin-exists.sh b/tests/01-cabin-exists.sh deleted file mode 100644 index 0dfc8144d..000000000 --- a/tests/01-cabin-exists.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh - -test_description='Check if the cabin binary exists' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'The cabin binary exists' ' - test -x "$CABIN" -' - -test_done diff --git a/tests/02-new.sh b/tests/02-new.sh deleted file mode 100644 index eb1c0c1ec..000000000 --- a/tests/02-new.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/sh - -test_description='Test the new command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin new bin hello_world' ' - test_when_finished "rm -rf hello_world" && - "$CABIN" new hello_world 2>actual && - ( - test_path_is_dir hello_world && - cd hello_world && - test_path_is_dir .git && - test_path_is_file .gitignore && - test_path_is_file cabin.toml && - test_path_is_dir src && - test_path_is_file src/main.cc - ) && - cat >expected <<-EOF && - Created binary (application) \`hello_world\` package -EOF - test_cmp expected actual -' - -test_expect_success 'cabin new lib hello_world' ' - test_when_finished "rm -rf hello_world" && - "$CABIN" new --lib hello_world 2>actual && - ( - test_path_is_dir hello_world && - cd hello_world && - test_path_is_dir .git && - test_path_is_file .gitignore && - test_path_is_file cabin.toml && - test_path_is_dir include - ) && - cat >expected <<-EOF && - Created library \`hello_world\` package -EOF - test_cmp expected actual -' - -test_expect_success 'cabin new empty' ' - test_must_fail "$CABIN" new 2>actual && - cat >expected <<-EOF && -Error: package name must not be empty -EOF - test_cmp expected actual -' - -test_expect_success 'cabin new existing' ' - test_when_finished "rm -rf existing" && - mkdir -p existing && - test_must_fail "$CABIN" new existing 2>actual && - cat >expected <<-EOF && -Error: directory \`existing\` already exists -EOF - test_cmp expected actual -' - -test_done diff --git a/tests/03-fmt.sh b/tests/03-fmt.sh deleted file mode 100644 index d5baa6579..000000000 --- a/tests/03-fmt.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/sh - -test_description='Test the fmt command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -command -v clang-format >/dev/null && test_set_prereq CLANG_FORMAT - -if ! test_have_prereq CLANG_FORMAT; then - test_expect_success 'cabin fmt without clang-format' ' - test_when_finished "rm -rf pkg" && - "$CABIN" new pkg && - cd pkg && - ( - test_must_fail "$CABIN" fmt 2>actual && - cat >expected <<-EOF && -Error: fmt command requires clang-format; try installing it by: - apt/brew install clang-format -EOF - test_cmp expected actual - ) - ' - - # Skip the rest of the tests - test_done -fi - -test_expect_success 'cabin fmt' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new pkg && - cd pkg && - ( - echo "int main(){}" >src/main.cc && - md5sum src/main.cc >before && - "$CABIN" fmt 2>actual && - md5sum src/main.cc >after && - test_must_fail test_cmp before after && - cat >expected <<-EOF && - Formatted 1 out of 1 file -EOF - test_cmp expected actual && - - md5sum src/main.cc >before && - "$CABIN" fmt 2>actual && - md5sum src/main.cc >after && - test_cmp before after && - cat >expected <<-EOF && - Formatted 0 out of 1 file -EOF - test_cmp expected actual - ) -' - -test_expect_success 'cabin fmt no targets' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new pkg && - cd pkg && - ( - rm src/main.cc && - "$CABIN" fmt 2>actual && - cat >expected <<-EOF && -Warning: no files to format -EOF - test_cmp expected actual - ) -' - -test_expect_success 'cabin fmt without manifest' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new pkg && - cd pkg && - ( - rm cabin.toml && - test_must_fail "$CABIN" fmt 2>actual && - cat >expected <<-EOF && -Error: cabin.toml not find in \`$(realpath $OUT)/pkg\` and its parents -EOF - test_cmp expected actual - ) -' - -test_expect_success 'cabin fmt without name in manifest' ' - echo $SHARNESS_TEST_OUTDIR && - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new pkg && - cd pkg && - ( - echo "[package]" >cabin.toml && - test_must_fail "$CABIN" fmt 2>actual && - cat >expected <<-EOF && -Error: toml::value::at: key "name" not found - --> $(realpath $OUT)/pkg/cabin.toml - | - 1 | [package] - | ^^^^^^^^^-- in this table -EOF - test_cmp expected actual - ) -' - -test_done diff --git a/tests/04-init.sh b/tests/04-init.sh deleted file mode 100644 index 446179dc6..000000000 --- a/tests/04-init.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/sh - -test_description='Test the init command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin init' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - mkdir $OUT/pkg && - cd $OUT/pkg && - "$CABIN" init 2>actual && - cat >expected <<-EOF && - Created binary (application) \`pkg\` package -EOF - test_cmp expected actual && - test_path_is_file cabin.toml -' - -test_expect_success 'cabin init existing' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - mkdir $OUT/pkg && - cd $OUT/pkg && - "$CABIN" init 2>actual && - cat >expected <<-EOF && - Created binary (application) \`pkg\` package -EOF - test_cmp expected actual && - test_path_is_file cabin.toml - test_must_fail "$CABIN" init 2>actual && - cat >expected <<-EOF && -Error: cannot initialize an existing cabin package -EOF - test_cmp expected actual && - test_path_is_file cabin.toml -' - -test_done diff --git a/tests/05-version.sh b/tests/05-version.sh deleted file mode 100644 index 68e4fbd91..000000000 --- a/tests/05-version.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -test_description='Test the version command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin version' ' - VERSION=$(grep -m1 version "$WHEREAMI"/../cabin.toml | cut -f 2 -d'\''"'\'') && - COMMIT_SHORT_HASH=$(git rev-parse --short=8 HEAD) && - COMMIT_DATE=$(git show -s --date=format-local:%Y-%m-%d --format=%cd) && - "$CABIN" version 1>actual && - cat >expected <<-EOF && -cabin $VERSION ($COMMIT_SHORT_HASH $COMMIT_DATE) -EOF - test_cmp expected actual -' - -test_expect_success 'cabin verbose version' ' - "$CABIN" -vV 1>actual1 && - "$CABIN" -Vv 1>actual2 && - test_cmp actual1 actual2 && - grep compiler actual1 -' - -test_done diff --git a/tests/06-run.sh b/tests/06-run.sh deleted file mode 100644 index 7823c39f8..000000000 --- a/tests/06-run.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/sh - -test_description='Test the run command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin run hello_world' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new hello_world && - cd hello_world && - "$CABIN" run 1>stdout 2>stderr && - ( - test_path_is_dir cabin-out && - test_path_is_dir cabin-out/dev && - test -x cabin-out/dev/hello_world - ) && - ( - TIME=$(cat stderr | grep Finished | grep -o '\''[0-9]\+\.[0-9]\+'\'') && - cat >stderr_exp <<-EOF && - Compiling hello_world v0.1.0 ($(realpath $OUT)/hello_world) - Finished \`dev\` profile [unoptimized + debuginfo] target(s) in ${TIME}s - Running \`cabin-out/dev/hello_world\` -EOF - test_cmp stderr_exp stderr - ) && - ( - cat >stdout_exp <<-EOF && -Hello, world! -EOF - test_cmp stdout_exp stdout - ) -' - -test_done diff --git a/tests/07-remove.sh b/tests/07-remove.sh deleted file mode 100644 index d51cbb78e..000000000 --- a/tests/07-remove.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -test_description='Test the remove command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin remove tbb mydep toml11' ' - test_when_finished "rm -rf remove_test" && - "$CABIN" new remove_test && - cd remove_test && - echo "[dependencies]" >> cabin.toml && - echo "tbb = {}" >> cabin.toml && - echo "toml11 = {}" >> cabin.toml && - ( - "$CABIN" remove tbb mydep toml11 2>actual && - ! grep -q "tbb" cabin.toml && - ! grep -q "toml11" cabin.toml - ) && - cat >expected <<-EOF && -Warning: Dependency \`mydep\` not found in $WHEREAMI/trash directory.07-remove.sh/remove_test/cabin.toml - Removed tbb, toml11 from $WHEREAMI/trash directory.07-remove.sh/remove_test/cabin.toml -EOF - test_cmp expected actual -' -test_done diff --git a/tests/08-test.sh b/tests/08-test.sh deleted file mode 100644 index c2a2c0f47..000000000 --- a/tests/08-test.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/bin/sh - -test_description='Test the test command' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin test basic functionality' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new test_project && - cd test_project && - - # Add a simple test to the main.cc file - cat >src/main.cc <<-EOF && -#include - -#ifdef CABIN_TEST -void test_addition() { - int result = 2 + 2; - if (result != 4) { - std::cerr << "Test failed: 2 + 2 = " << result << ", expected 4" << std::endl; - std::exit(1); - } - std::cout << "test test addition ... ok" << std::endl; -} - -int main() { - test_addition(); - return 0; -} -#else -int main() { - std::cout << "Hello, world!" << std::endl; - return 0; -} -#endif -EOF - - "$CABIN" test 1>stdout 2>stderr && - ( - test_path_is_dir cabin-out && - test_path_is_dir cabin-out/test && - test_path_is_dir cabin-out/test/unittests - ) && - grep -q "test addition.*ok" stdout && - grep -q "1 passed; 0 failed" stderr -' - -test_expect_success 'cabin test --help shows coverage option' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new test_project && - cd test_project && - "$CABIN" test --help >help_output 2>&1 && - grep -q -- "--coverage" help_output && - grep -q "Enable code coverage analysis" help_output -' - -test_expect_success 'cabin test --coverage generates coverage files' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new coverage_project && - cd coverage_project && - - # Add a simple test - cat >src/main.cc <<-EOF && -#include - -#ifdef CABIN_TEST -void test_function() { - std::cout << "test coverage function ... ok" << std::endl; -} - -int main() { - test_function(); - return 0; -} -#else -int main() { - std::cout << "Hello, world!" << std::endl; - return 0; -} -#endif -EOF - - "$CABIN" test --coverage 1>stdout 2>stderr && - - # Check that coverage files were generated - find cabin-out/test -name "*.gcda" | head -1 | grep -q "\.gcda$" && - find cabin-out/test -name "*.gcno" | head -1 | grep -q "\.gcno$" && - - # Check test output - grep -q "coverage function.*ok" stdout && - grep -q "1 passed; 0 failed" stderr -' - -test_expect_success 'cabin test --coverage uses coverage flags in compilation' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new verbose_project && - cd verbose_project && - - # Add a simple test - cat >src/main.cc <<-EOF && -#include - -#ifdef CABIN_TEST -int main() { - std::cout << "test verbose compilation ... ok" << std::endl; - return 0; -} -#else -int main() { - std::cout << "Hello, world!" << std::endl; - return 0; -} -#endif -EOF - - # Clear any existing build artifacts to force recompilation - rm -rf cabin-out && - - "$CABIN" test --coverage -vv 1>stdout 2>stderr && - test_when_finished "rm -rf cabin-out/coverage" && - - # Check that --coverage flag appears in compilation commands - grep -q -- "--coverage" stdout && - - # Check test passes - grep -q "verbose compilation.*ok" stdout && - grep -q "1 passed; 0 failed" stderr -' - -test_expect_success 'cabin test without --coverage does not generate coverage files' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd $OUT && - "$CABIN" new no_coverage_project && - cd no_coverage_project && - - # Add a simple test - cat >src/main.cc <<-EOF && -#include - -#ifdef CABIN_TEST -int main() { - std::cout << "test no coverage ... ok" << std::endl; - return 0; -} -#else -int main() { - std::cout << "Hello, world!" << std::endl; - return 0; -} -#endif -EOF - - "$CABIN" test 1>stdout 2>stderr && - - # Check that no coverage files were generated in a clean test - # (Note: there might be some from previous tests, so we check that coverage files are not created) - test $(find cabin-out/test -name "*.gcda" | wc -l) -eq 0 && - - # Check test passes - grep -q "no coverage.*ok" stdout && - grep -q "1 passed; 0 failed" stderr -' - -test_done diff --git a/tests/09-build-ninja.sh b/tests/09-build-ninja.sh deleted file mode 100755 index 2b744aeb7..000000000 --- a/tests/09-build-ninja.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh - -test_description='cabin build emits Ninja build files' - -WHEREAMI=$(dirname "$(realpath "$0")") -. $WHEREAMI/setup.sh - -test_expect_success 'cabin build generates Ninja files and uses ninja' ' - OUT=$(mktemp -d) && - test_when_finished "rm -rf $OUT" && - cd "$OUT" && - - "$CABIN" new ninja_project && - cd ninja_project && - - "$CABIN" build && - - test_path_is_file cabin-out/dev/build.ninja && - test_path_is_file cabin-out/dev/config.ninja && - test_path_is_file cabin-out/dev/rules.ninja && - test_path_is_file cabin-out/dev/targets.ninja && - test_path_is_file cabin-out/dev/ninja_project && - test_path_is_dir cabin-out/dev/ninja_project.d && - test ! -e cabin-out/dev/Makefile -' - -test_done diff --git a/tests/build.cc b/tests/build.cc new file mode 100644 index 000000000..409f38797 --- /dev/null +++ b/tests/build.cc @@ -0,0 +1,26 @@ +#include "helpers.hpp" + +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin build emits ninja"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "ninja_project" }, tmp.path).unwrap(); + const auto project = tmp.path / "ninja_project"; + + const auto result = tests::runCabin({ "build" }, project).unwrap(); + expect(result.status.success()) << result.status.toString(); + + const auto outDir = project / "cabin-out" / "dev"; + expect(tests::fs::is_regular_file(outDir / "build.ninja")); + expect(tests::fs::is_regular_file(outDir / "config.ninja")); + expect(tests::fs::is_regular_file(outDir / "rules.ninja")); + expect(tests::fs::is_regular_file(outDir / "targets.ninja")); + expect(tests::fs::is_regular_file(outDir / "ninja_project")); + expect(tests::fs::is_directory(outDir / "ninja_project.d")); + expect(!tests::fs::exists(outDir / "Makefile")); + }; +} diff --git a/tests/cabin_exists.cc b/tests/cabin_exists.cc new file mode 100644 index 000000000..c3079afb2 --- /dev/null +++ b/tests/cabin_exists.cc @@ -0,0 +1,15 @@ +#include "helpers.hpp" + +#include +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin binary exists"_test = [] { + const auto bin = tests::cabinBinary(); + expect(tests::fs::exists(bin)) << "expected cabin binary"; + expect(::access(bin.c_str(), X_OK) == 0) << "binary should be executable"; + }; +} diff --git a/tests/fmt.cc b/tests/fmt.cc new file mode 100644 index 000000000..aeae3802d --- /dev/null +++ b/tests/fmt.cc @@ -0,0 +1,122 @@ +#include "helpers.hpp" + +#include +#include +#include +#include + +namespace { + +bool hasClangFormat() { return tests::hasCommand("clang-format"); } + +} // namespace + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "fmt without clang-format"_test = [] { + if (hasClangFormat()) { + expect(true) << "clang-format available"; + return; + } + + tests::TempDir tmp; + const auto out = tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); + expect(out.status.success()); + + const auto project = tmp.path / "pkg"; + const auto fmtResult = tests::runCabin({ "fmt" }, project).unwrap(); + expect(!fmtResult.status.success()); + auto sanitizedOut = tests::sanitizeOutput(fmtResult.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(fmtResult.err); + const std::string expectedErr = + "Error: fmt command requires clang-format; try installing it by:\n" + " apt/brew install clang-format\n"; + expect(sanitizedErr == expectedErr); + }; + + "fmt formats source"_test = [] { + if (!hasClangFormat()) { + expect(true) << "skipped: clang-format unavailable"; + return; + } + + tests::TempDir tmp; + tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); + + const auto project = tmp.path / "pkg"; + const auto mainFile = project / "src/main.cc"; + tests::writeFile(mainFile, "int main(){}\n"); + + const auto before = tests::readFile(mainFile); + const auto firstFmt = tests::runCabin({ "fmt" }, project).unwrap(); + expect(firstFmt.status.success()) << firstFmt.status.toString(); + auto sanitizedFirstOut = tests::sanitizeOutput(firstFmt.out); + expect(sanitizedFirstOut.empty()); + auto sanitizedFirstErr = tests::sanitizeOutput(firstFmt.err); + const std::string expectedFirstErr = " Formatted 1 out of 1 file\n"; + expect(sanitizedFirstErr == expectedFirstErr); + + const auto afterFirst = tests::readFile(mainFile); + expect(afterFirst != before) << "file should be reformatted"; + + const auto secondFmt = tests::runCabin({ "fmt" }, project).unwrap(); + expect(secondFmt.status.success()); + auto sanitizedSecondOut = tests::sanitizeOutput(secondFmt.out); + expect(sanitizedSecondOut.empty()); + auto sanitizedSecondErr = tests::sanitizeOutput(secondFmt.err); + const std::string expectedSecondErr = " Formatted 0 out of 1 file\n"; + expect(sanitizedSecondErr == expectedSecondErr); + + const auto afterSecond = tests::readFile(mainFile); + expect(afterSecond == afterFirst); + }; + + "fmt without targets"_test = [] { + if (!hasClangFormat()) { + expect(true) << "skipped: clang-format unavailable"; + return; + } + + tests::TempDir tmp; + tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); + + const auto project = tmp.path / "pkg"; + tests::fs::remove(project / "src/main.cc"); + + const auto result = tests::runCabin({ "fmt" }, project).unwrap(); + expect(result.status.success()); + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(result.err); + const std::string expectedErr = "Warning: no files to format\n"; + expect(sanitizedErr == expectedErr); + }; + + "fmt missing manifest"_test = [] { + if (!hasClangFormat()) { + expect(true) << "skipped: clang-format unavailable"; + return; + } + + tests::TempDir tmp; + tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); + + const auto project = tests::fs::path(tmp.path) / "pkg"; + tests::fs::remove(project / "cabin.toml"); + + const auto result = tests::runCabin({ "fmt" }, project).unwrap(); + expect(!result.status.success()); + + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + const auto canonical = tests::fs::weakly_canonical(project); + auto sanitizedErr = tests::sanitizeOutput( + result.err, { { canonical.string(), "" } }); + const std::string expectedErr = + "Error: cabin.toml not find in `` and its parents\n"; + expect(sanitizedErr == expectedErr); + }; +} diff --git a/tests/helpers.hpp b/tests/helpers.hpp new file mode 100644 index 000000000..22f3d5903 --- /dev/null +++ b/tests/helpers.hpp @@ -0,0 +1,194 @@ +#pragma once + +#include "Algos.hpp" +#include "Command.hpp" +#include "Manifest.hpp" +#include "Rustify/Result.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tests { + +namespace fs = std::filesystem; + +inline std::string readFile(const fs::path& file); + +inline const fs::path& projectRoot() { + static const fs::path root = [] { + auto manifest = cabin::Manifest::tryParse().unwrap(); + return manifest.path.parent_path(); + }(); + return root; +} + +inline fs::path cabinBinary() { + if (const char* env = std::getenv("CABIN")) { + return fs::path(env); + } + const auto& root = projectRoot(); + const std::array candidates = { + root / "build" / "cabin", + root / "cabin-out" / "dev" / "cabin", + }; + for (const auto& candidate : candidates) { + if (fs::exists(candidate)) { + return candidate; + } + } + return candidates.front(); +} + +struct RunResult { + cabin::ExitStatus status; + std::string out; + std::string err; +}; + +inline std::string replaceAll(std::string text, std::string_view from, + std::string_view to) { + if (from.empty()) { + return text; + } + std::size_t pos = 0; + while ((pos = text.find(from, pos)) != std::string::npos) { + text.replace(pos, from.size(), to); + pos += to.size(); + } + return text; +} + +inline std::string scrubDurations(std::string text) { + static const std::regex pattern(R"(in [0-9]+\.[0-9]+s)"); + return std::regex_replace(text, pattern, "in s"); +} + +inline std::string scrubIsoDates(std::string text) { + static const std::regex pattern(R"([0-9]{4}-[0-9]{2}-[0-9]{2})"); + return std::regex_replace(text, pattern, ""); +} + +inline std::string sanitizeOutput( + std::string text, + std::initializer_list> + replacements = {}) { + for (const auto& [from, to] : replacements) { + text = replaceAll(std::move(text), from, to); + } + text = scrubDurations(std::move(text)); + text = scrubIsoDates(std::move(text)); + text = std::regex_replace(std::move(text), std::regex(R"(\b[0-9a-f]{40}\b)"), + ""); + text = std::regex_replace(std::move(text), std::regex(R"(\b[0-9a-f]{8}\b)"), + ""); + text = std::regex_replace( + std::move(text), + std::regex(R"(^compiler: .*$)", std::regex_constants::multiline), + "compiler: "); + text = std::regex_replace( + std::move(text), + std::regex(R"(^libgit2: .*$)", std::regex_constants::multiline), + "libgit2: "); + text = std::regex_replace( + std::move(text), + std::regex(R"(^libcurl: .*$)", std::regex_constants::multiline), + "libcurl: "); + return text; +} + +inline Result runCabin(const std::vector& args, + const fs::path& workdir = {}) { + cabin::Command cmd(cabinBinary().string()); + for (const auto& arg : args) { + cmd.addArg(arg); + } + if (!workdir.empty()) { + cmd.setWorkingDirectory(workdir); + } + cmd.setStdOutConfig(cabin::Command::IOConfig::Piped); + cmd.setStdErrConfig(cabin::Command::IOConfig::Piped); + + const cabin::CommandOutput output = Try(cmd.output()); + return Ok(RunResult{ output.exitStatus, output.stdOut, output.stdErr }); +} + +inline Result runCabin(std::initializer_list args, + const fs::path& workdir = {}) { + return runCabin(std::vector(args), workdir); +} + +struct TempDir { + fs::path path; + + TempDir() + : path([] { + const auto epoch = + std::chrono::steady_clock::now().time_since_epoch(); + const auto ticks = + std::chrono::duration_cast(epoch) + .count(); + std::ostringstream oss; + oss << "cabin-test-" << static_cast(::getpid()) << '-' + << ticks; + return fs::temp_directory_path() / oss.str(); + }()) { + fs::create_directories(path); + } + + ~TempDir() { + if (path.empty()) { + return; + } + std::error_code ec; + fs::remove_all(path, ec); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + TempDir(TempDir&& other) noexcept : path(std::move(other.path)) { + other.path.clear(); + } + + TempDir& operator=(TempDir&& other) noexcept { + if (this != &other) { + path = std::move(other.path); + other.path.clear(); + } + return *this; + } + + [[nodiscard]] fs::path operator/(const fs::path& relative) const { + return path / relative; + } +}; + +inline std::string readFile(const fs::path& file) { + std::ifstream ifs(file); + return std::string(std::istreambuf_iterator(ifs), {}); +} + +inline void writeFile(const fs::path& file, const std::string& content) { + std::ofstream ofs(file); + ofs << content; +} + +inline bool hasCommand(std::string_view name) { + return cabin::commandExists(name); +} + +} // namespace tests diff --git a/tests/init.cc b/tests/init.cc new file mode 100644 index 000000000..e95f80fc8 --- /dev/null +++ b/tests/init.cc @@ -0,0 +1,50 @@ +#include "helpers.hpp" + +#include +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin init"_test = [] { + tests::TempDir tmp; + const auto project = tmp.path / "pkg"; + tests::fs::create_directories(project); + + const auto result = tests::runCabin({ "init" }, project).unwrap(); + expect(result.status.success()) << result.status.toString(); + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(result.err); + const std::string expectedErr = + " Created binary (application) `pkg` package\n"; + expect(sanitizedErr == expectedErr); + expect(tests::fs::is_regular_file(project / "cabin.toml")); + }; + + "cabin init existing"_test = [] { + tests::TempDir tmp; + const auto project = tmp.path / "pkg"; + tests::fs::create_directories(project); + + const auto first = tests::runCabin({ "init" }, project).unwrap(); + expect(first.status.success()); + auto firstOut = tests::sanitizeOutput(first.out); + expect(firstOut.empty()); + auto firstErr = tests::sanitizeOutput(first.err); + const std::string expectedFirstErr = + " Created binary (application) `pkg` package\n"; + expect(firstErr == expectedFirstErr); + + const auto second = tests::runCabin({ "init" }, project).unwrap(); + expect(!second.status.success()); + auto secondOut = tests::sanitizeOutput(second.out); + expect(secondOut.empty()); + auto secondErr = tests::sanitizeOutput(second.err); + const std::string expectedSecondErr = + "Error: cannot initialize an existing cabin package\n"; + expect(secondErr == expectedSecondErr); + expect(tests::fs::is_regular_file(project / "cabin.toml")); + }; +} diff --git a/tests/new.cc b/tests/new.cc new file mode 100644 index 000000000..f487405c9 --- /dev/null +++ b/tests/new.cc @@ -0,0 +1,83 @@ +#include "helpers.hpp" + +#include +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin new binary"_test = [] { + tests::TempDir tmp; + const auto result = + tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); + + expect(result.status.success()) << result.status.toString(); + + const auto project = tmp.path / "hello_world"; + expect(tests::fs::is_directory(project)) << "project directory"; + expect(tests::fs::is_directory(project / ".git")) << "git repo"; + expect(tests::fs::is_regular_file(project / ".gitignore")); + expect(tests::fs::is_regular_file(project / "cabin.toml")); + expect(tests::fs::is_directory(project / "src")); + expect(tests::fs::is_regular_file(project / "src/main.cc")); + + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(result.err); + const std::string expectedErr = + " Created binary (application) `hello_world` package\n"; + expect(sanitizedErr == expectedErr); + }; + + "cabin new library"_test = [] { + tests::TempDir tmp; + const auto result = + tests::runCabin({ "new", "--lib", "hello_world" }, tmp.path).unwrap(); + + expect(result.status.success()) << result.status.toString(); + + const auto project = tmp.path / "hello_world"; + expect(tests::fs::is_directory(project)); + expect(tests::fs::is_directory(project / ".git")); + expect(tests::fs::is_regular_file(project / ".gitignore")); + expect(tests::fs::is_regular_file(project / "cabin.toml")); + expect(tests::fs::is_directory(project / "include")); + + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(result.err); + const std::string expectedErr = + " Created library `hello_world` package\n"; + expect(sanitizedErr == expectedErr); + }; + + "cabin new requires name"_test = [] { + tests::TempDir tmp; + const auto result = tests::runCabin({ "new" }, tmp.path).unwrap(); + + expect(!result.status.success()); + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(result.err); + const std::string expectedErr = "Error: package name must not be empty\n"; + expect(sanitizedErr == expectedErr); + }; + + "cabin new existing"_test = [] { + tests::TempDir tmp; + const auto project = tmp.path / "existing"; + tests::fs::create_directories(project); + + const auto result = + tests::runCabin({ "new", "existing" }, tmp.path).unwrap(); + + expect(!result.status.success()); + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(result.err); + const std::string expectedErr = + "Error: directory `existing` already exists\n"; + expect(sanitizedErr == expectedErr); + }; +} diff --git a/tests/remove.cc b/tests/remove.cc new file mode 100644 index 000000000..e19d6d632 --- /dev/null +++ b/tests/remove.cc @@ -0,0 +1,46 @@ +#include "helpers.hpp" + +#include +#include +#include +#include +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin remove"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "remove_test" }, tmp.path).unwrap(); + + const auto project = tmp.path / "remove_test"; + const auto manifest = project / "cabin.toml"; + + std::ofstream ofs(manifest, std::ios::app); + ofs << "[dependencies]\n"; + ofs << "tbb = {}\n"; + ofs << "toml11 = {}\n"; + ofs.close(); + + const auto result = + tests::runCabin({ "remove", "tbb", "mydep", "toml11" }, project) + .unwrap(); + + expect(result.status.success()); + + const auto manifestContent = tests::readFile(manifest); + expect(manifestContent.find("tbb") == std::string::npos); + expect(manifestContent.find("toml11") == std::string::npos); + + const auto manifestPath = tests::fs::weakly_canonical(manifest).string(); + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = + tests::sanitizeOutput(result.err, { { manifestPath, "" } }); + const std::string expectedErr = + "Warning: Dependency `mydep` not found in \n" + " Removed tbb, toml11 from \n"; + expect(sanitizedErr == expectedErr); + }; +} diff --git a/tests/run.cc b/tests/run.cc new file mode 100644 index 000000000..e35cc2603 --- /dev/null +++ b/tests/run.cc @@ -0,0 +1,40 @@ +#include "helpers.hpp" + +#include +#include +#include +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin run"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); + + const auto project = tmp.path / "hello_world"; + const auto result = tests::runCabin({ "run" }, project).unwrap(); + + expect(result.status.success()) << result.status.toString(); + auto sanitizedOut = tests::sanitizeOutput(result.out); + expect(sanitizedOut == "Hello, world!\n"); + const auto projectPath = tests::fs::weakly_canonical(project).string(); + auto sanitizedErr = + tests::sanitizeOutput(result.err, { { projectPath, "" } }); + const std::string expectedErr = + " Compiling hello_world v0.1.0 ()\n" + " Finished `dev` profile [unoptimized + debuginfo] target(s) in " + "s\n" + " Running `cabin-out/dev/hello_world`\n"; + expect(sanitizedErr == expectedErr); + + expect(tests::fs::is_directory(project / "cabin-out")); + expect(tests::fs::is_directory(project / "cabin-out/dev")); + expect(tests::fs::is_regular_file(project / "cabin-out/dev/hello_world")); + + expect(result.err.contains("Compiling hello_world v0.1.0")); + expect(result.err.contains("Finished `dev` profile")); + expect(result.err.contains("Running `cabin-out/dev/hello_world`")); + }; +} diff --git a/tests/setup.sh b/tests/setup.sh deleted file mode 100644 index 7e64bc8e5..000000000 --- a/tests/setup.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -export WHEREAMI=$(dirname "$(realpath "$0")") -export CABIN="${CABIN:-"$WHEREAMI/../build/cabin"}" - -SAVETZ=${TZ:-UTC} - -. $WHEREAMI/sharness.sh - -export TZ=$SAVETZ diff --git a/tests/test.cc b/tests/test.cc new file mode 100644 index 000000000..6fbff8e6c --- /dev/null +++ b/tests/test.cc @@ -0,0 +1,210 @@ +#include "helpers.hpp" + +#include +#include +#include +#include +#include + +namespace { + +std::size_t countFiles(const tests::fs::path& root, + std::string_view extension) { + if (!tests::fs::exists(root)) { + return 0; + } + std::size_t count = 0; + for (const auto& entry : tests::fs::recursive_directory_iterator(root)) { + if (entry.path().extension() == extension) { + ++count; + } + } + return count; +} + +std::string expectedTestSummary(std::string_view projectName) { + return fmt::format( + " Compiling {} v0.1.0 ()\n" + " Finished `test` profile [unoptimized + debuginfo] target(s) in " + "s\n" + " Running unit test src/main.cc (cabin-out/test/unit/main.cc.test)\n" + " Ok 1 passed; 0 failed; finished in s\n", + projectName); +} + +} // namespace + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin test basic"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "test_project" }, tmp.path).unwrap(); + + const auto project = tmp.path / "test_project"; + const auto projectPath = tests::fs::weakly_canonical(project).string(); + tests::writeFile(project / "src/main.cc", + R"( #include + +#ifdef CABIN_TEST +void test_addition() { + int result = 2 + 2; + if (result != 4) { + std::cerr << "Test failed: 2 + 2 = " << result << ", expected 4" << std::endl; + std::exit(1); + } + std::cout << "test test addition ... ok" << std::endl; +} + +int main() { + test_addition(); + return 0; +} +#else +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} +#endif +)"); + + const auto result = tests::runCabin({ "test" }, project).unwrap(); + expect(result.status.success()) << result.status.toString(); + auto sanitizedOut = tests::sanitizeOutput( + result.out, { { projectPath, "" } }); // NOLINT + expect(sanitizedOut == "test test addition ... ok\n"); + auto sanitizedErr = tests::sanitizeOutput( + result.err, { { projectPath, "" } }); // NOLINT + expect(sanitizedErr == expectedTestSummary("test_project")); + + expect(tests::fs::is_directory(project / "cabin-out" / "test")); + expect(tests::fs::is_directory(project / "cabin-out" / "test" / "unit")); + }; + + "cabin test help"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "test_project" }, tmp.path).unwrap(); + const auto project = tmp.path / "test_project"; + const auto projectPath = tests::fs::weakly_canonical(project).string(); + + const auto result = tests::runCabin({ "test", "--help" }, project).unwrap(); + expect(result.status.success()); + auto sanitizedOut = tests::sanitizeOutput( + result.out, { { projectPath, "" } }); // NOLINT + expect(sanitizedOut.contains("--coverage")); + auto sanitizedErr = tests::sanitizeOutput(result.err); + expect(sanitizedErr.empty()); + }; + + "cabin test coverage"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "coverage_project" }, tmp.path).unwrap(); + const auto project = tmp.path / "coverage_project"; + const auto projectPath = tests::fs::weakly_canonical(project).string(); + + tests::writeFile(project / "src/main.cc", + R"(#include + +#ifdef CABIN_TEST +void test_function() { + std::cout << "test coverage function ... ok" << std::endl; +} + +int main() { + test_function(); + return 0; +} +#else +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} +#endif +)"); + + const auto result = + tests::runCabin({ "test", "--coverage" }, project).unwrap(); + expect(result.status.success()); + auto sanitizedOut = tests::sanitizeOutput( + result.out, { { projectPath, "" } }); // NOLINT + expect(sanitizedOut == "test coverage function ... ok\n"); + auto sanitizedErr = tests::sanitizeOutput( + result.err, { { projectPath, "" } }); // NOLINT + expect(sanitizedErr == expectedTestSummary("coverage_project")); + + const auto outDir = project / "cabin-out" / "test"; + expect(countFiles(outDir, ".gcda") > 0); + expect(countFiles(outDir, ".gcno") > 0); + }; + + "cabin test verbose coverage"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "verbose_project" }, tmp.path).unwrap(); + const auto project = tmp.path / "verbose_project"; + const auto projectPath = tests::fs::weakly_canonical(project).string(); + + tests::writeFile(project / "src/main.cc", + R"(#include + +#ifdef CABIN_TEST +int main() { + std::cout << "test verbose compilation ... ok" << std::endl; + return 0; +} +#else +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} +#endif +)"); + + tests::fs::remove_all(project / "cabin-out"); + + const auto result = + tests::runCabin({ "test", "--coverage", "-vv" }, project).unwrap(); + expect(result.status.success()); + auto sanitizedOut = tests::sanitizeOutput( + result.out, { { projectPath, "" } }); // NOLINT + expect(sanitizedOut.contains("--coverage")); + auto sanitizedErr = tests::sanitizeOutput( + result.err, { { projectPath, "" } }); // NOLINT + expect(sanitizedErr == expectedTestSummary("verbose_project")); + }; + + "cabin test without coverage"_test = [] { + tests::TempDir tmp; + tests::runCabin({ "new", "no_coverage_project" }, tmp.path).unwrap(); + const auto project = tmp.path / "no_coverage_project"; + const auto projectPath = tests::fs::weakly_canonical(project).string(); + + tests::writeFile(project / "src/main.cc", + R"(#include + +#ifdef CABIN_TEST +int main() { + std::cout << "test no coverage ... ok" << std::endl; + return 0; +} +#else +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} +#endif +)"); + + const auto result = tests::runCabin({ "test" }, project).unwrap(); + expect(result.status.success()); + auto sanitizedOut = tests::sanitizeOutput( + result.out, { { projectPath, "" } }); // NOLINT + expect(sanitizedOut == "test no coverage ... ok\n"); + auto sanitizedErr = tests::sanitizeOutput( + result.err, { { projectPath, "" } }); // NOLINT + expect(sanitizedErr == expectedTestSummary("no_coverage_project")); + + const auto outDir = project / "cabin-out" / "test"; + expect(countFiles(outDir, ".gcda") == 0u); + }; +} diff --git a/tests/version.cc b/tests/version.cc new file mode 100644 index 000000000..e955008e6 --- /dev/null +++ b/tests/version.cc @@ -0,0 +1,50 @@ +#include "Git2.hpp" +#include "Manifest.hpp" +#include "helpers.hpp" + +#include +#include +#include +#include + +namespace { + +std::string readVersion() { + auto manifest = cabin::Manifest::tryParse().unwrap(); + return manifest.package.version.toString(); +} + +} // namespace + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + + "cabin version"_test = [] { + const auto version = readVersion(); + expect(!version.empty()); + + auto trim = [](std::string s) { + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) { + s.pop_back(); + } + return s; + }; + + const auto result = tests::runCabin({ "version" }).unwrap(); + expect(result.status.success()); + const auto actual = trim(result.out); + static const std::regex pattern( + R"(^cabin ([^\s]+) \(([0-9a-f]{8}) ([0-9]{4}-[0-9]{2}-[0-9]{2})\)$)"); + std::smatch match; + expect(std::regex_match(actual, match, pattern)); + expect(match[1].str() == version); + + auto sanitizedOut = tests::sanitizeOutput(result.out); + const std::string expectedOut = + fmt::format("cabin {} ( )\n", version); + expect(sanitizedOut == expectedOut); + auto sanitizedErr = tests::sanitizeOutput(result.err); + expect(sanitizedErr.empty()); + }; +} From 124f5edc2b86a331c89213aa158123e20538812c Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:32:38 -0400 Subject: [PATCH 02/11] fix(tests): remove a test that is hard to test --- tests/fmt.cc | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/tests/fmt.cc b/tests/fmt.cc index aeae3802d..a01d5a680 100644 --- a/tests/fmt.cc +++ b/tests/fmt.cc @@ -15,28 +15,6 @@ int main() { using boost::ut::expect; using boost::ut::operator""_test; - "fmt without clang-format"_test = [] { - if (hasClangFormat()) { - expect(true) << "clang-format available"; - return; - } - - tests::TempDir tmp; - const auto out = tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); - expect(out.status.success()); - - const auto project = tmp.path / "pkg"; - const auto fmtResult = tests::runCabin({ "fmt" }, project).unwrap(); - expect(!fmtResult.status.success()); - auto sanitizedOut = tests::sanitizeOutput(fmtResult.out); - expect(sanitizedOut.empty()); - auto sanitizedErr = tests::sanitizeOutput(fmtResult.err); - const std::string expectedErr = - "Error: fmt command requires clang-format; try installing it by:\n" - " apt/brew install clang-format\n"; - expect(sanitizedErr == expectedErr); - }; - "fmt formats source"_test = [] { if (!hasClangFormat()) { expect(true) << "skipped: clang-format unavailable"; From 9bf5cb73d362ed658fd7428de4460dda4fa4fc94 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:33:10 -0400 Subject: [PATCH 03/11] Revert "fix(tests): remove a test that is hard to test" This reverts commit 124f5edc2b86a331c89213aa158123e20538812c. --- tests/fmt.cc | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/fmt.cc b/tests/fmt.cc index a01d5a680..aeae3802d 100644 --- a/tests/fmt.cc +++ b/tests/fmt.cc @@ -15,6 +15,28 @@ int main() { using boost::ut::expect; using boost::ut::operator""_test; + "fmt without clang-format"_test = [] { + if (hasClangFormat()) { + expect(true) << "clang-format available"; + return; + } + + tests::TempDir tmp; + const auto out = tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); + expect(out.status.success()); + + const auto project = tmp.path / "pkg"; + const auto fmtResult = tests::runCabin({ "fmt" }, project).unwrap(); + expect(!fmtResult.status.success()); + auto sanitizedOut = tests::sanitizeOutput(fmtResult.out); + expect(sanitizedOut.empty()); + auto sanitizedErr = tests::sanitizeOutput(fmtResult.err); + const std::string expectedErr = + "Error: fmt command requires clang-format; try installing it by:\n" + " apt/brew install clang-format\n"; + expect(sanitizedErr == expectedErr); + }; + "fmt formats source"_test = [] { if (!hasClangFormat()) { expect(true) << "skipped: clang-format unavailable"; From bb237d9d05f9cd8fcd2dbd89c4f8b54feaba4c26 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:36:33 -0400 Subject: [PATCH 04/11] fix: remove unused boost-ut from Makefile --- Makefile | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Makefile b/Makefile index c342a60ee..d7118624a 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,6 @@ DEFINES := -DCABIN_CABIN_PKG_VERSION='"$(VERSION)"' \ -DCABIN_CABIN_COMMIT_DATE='"$(COMMIT_DATE)"' INCLUDES := -Isrc -isystem $(O)/DEPS/toml11/include \ -isystem $(O)/DEPS/mitama-cpp-result/include \ - -isystem $(O)/DEPS/boost-ut/include \ $(shell pkg-config --cflags '$(LIBGIT2_VERREQ)') \ $(shell pkg-config --cflags '$(LIBCURL_VERREQ)') \ $(shell pkg-config --cflags '$(NLOHMANN_JSON_VERREQ)') \ @@ -106,8 +105,3 @@ $(O)/DEPS/mitama-cpp-result: $(MKDIR_P) $(@D) $(GIT) clone https://github.com/loliGothicK/mitama-cpp-result.git $@ $(GIT) -C $@ reset --hard $(RESULT_VER) - -$(O)/DEPS/boost-ut: - $(MKDIR_P) $(@D) - $(GIT) clone https://github.com/boost-ext/ut.git $@ - $(GIT) -C $@ reset --hard $(RESULT_VER) From 364aa0afedf4f347f3e9225fb0a8d60289861c7b Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:38:32 -0400 Subject: [PATCH 05/11] fix: set CABIN_TERM_COLOR to never --- tests/helpers.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers.hpp b/tests/helpers.hpp index 22f3d5903..d92c0f36b 100644 --- a/tests/helpers.hpp +++ b/tests/helpers.hpp @@ -112,6 +112,7 @@ inline std::string sanitizeOutput( inline Result runCabin(const std::vector& args, const fs::path& workdir = {}) { + ::setenv("CABIN_TERM_COLOR", "never", 1); cabin::Command cmd(cabinBinary().string()); for (const auto& arg : args) { cmd.addArg(arg); From 82fa212c53420361f4a917056eafa9fd11addcc4 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:00:46 -0400 Subject: [PATCH 06/11] fix: run --coverage earlier (TODO: should rebuild when flag change --- .github/actions/build-and-test/action.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/build-and-test/action.yml b/.github/actions/build-and-test/action.yml index e518195e6..940745d0a 100644 --- a/.github/actions/build-and-test/action.yml +++ b/.github/actions/build-and-test/action.yml @@ -33,7 +33,10 @@ runs: - name: Stage 1 - Test shell: bash - run: ./build/cabin test -vv + run: | + [[ '${{ inputs.coverage }}' == 'true' ]] && COVERAGE='--coverage' + # shellcheck disable=SC2086 + ./build/cabin test -vv $COVERAGE env: CABIN: ${{ github.workspace }}/build/cabin @@ -50,9 +53,6 @@ runs: - name: Stage 2 - Test shell: bash - run: | - [[ '${{ inputs.coverage }}' == 'true' ]] && COVERAGE='--coverage' - # shellcheck disable=SC2086 - ./cabin-out/${{ inputs.build }}/cabin test -vv $COVERAGE + run: ./cabin-out/${{ inputs.build }}/cabin test -vv env: CABIN: ${{ github.workspace }}/cabin-out/${{ inputs.build }}/cabin From 9ee369bdb6d72acc817acf6311b2d2bff37d2faa Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:01:14 -0400 Subject: [PATCH 07/11] chore: add setEnv to Command --- src/Command.cc | 7 +++++++ src/Command.hpp | 5 +++++ tests/helpers.hpp | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Command.cc b/src/Command.cc index 4f406a6c8..95214458b 100644 --- a/src/Command.cc +++ b/src/Command.cc @@ -227,6 +227,13 @@ Result Command::spawn() const noexcept { } } + for (const auto& [key, value] : environment) { + if (setenv(key.c_str(), value.c_str(), 1) == -1) { + perror("setenv() failed"); + _exit(1); + } + } + // Execute the command if (execvp(command.c_str(), args.data()) == -1) { perror("execvp() failed"); diff --git a/src/Command.hpp b/src/Command.hpp index 7ca282a55..0381fd1d9 100644 --- a/src/Command.hpp +++ b/src/Command.hpp @@ -69,6 +69,7 @@ struct Command { std::filesystem::path workingDirectory; IOConfig stdOutConfig = IOConfig::Inherit; IOConfig stdErrConfig = IOConfig::Inherit; + std::vector> environment; explicit Command(std::string_view cmd) : command(cmd) {} Command(std::string_view cmd, std::vector args) @@ -103,6 +104,10 @@ struct Command { workingDirectory = dir; return *this; } + Command& setEnv(std::string key, std::string value) { + environment.emplace_back(std::move(key), std::move(value)); + return *this; + } std::string toString() const; diff --git a/tests/helpers.hpp b/tests/helpers.hpp index d92c0f36b..63f92991c 100644 --- a/tests/helpers.hpp +++ b/tests/helpers.hpp @@ -112,8 +112,8 @@ inline std::string sanitizeOutput( inline Result runCabin(const std::vector& args, const fs::path& workdir = {}) { - ::setenv("CABIN_TERM_COLOR", "never", 1); cabin::Command cmd(cabinBinary().string()); + cmd.setEnv("CABIN_TERM_COLOR", "never"); for (const auto& arg : args) { cmd.addArg(arg); } From c1f1cd2d13525b4ff948c2ff9a692bc199c2311e Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:15:23 -0400 Subject: [PATCH 08/11] chore: remove posix headers --- tests/cabin_exists.cc | 8 ++++++-- tests/helpers.hpp | 7 ++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/cabin_exists.cc b/tests/cabin_exists.cc index c3079afb2..afd7ea618 100644 --- a/tests/cabin_exists.cc +++ b/tests/cabin_exists.cc @@ -1,7 +1,6 @@ #include "helpers.hpp" #include -#include int main() { using boost::ut::expect; @@ -10,6 +9,11 @@ int main() { "cabin binary exists"_test = [] { const auto bin = tests::cabinBinary(); expect(tests::fs::exists(bin)) << "expected cabin binary"; - expect(::access(bin.c_str(), X_OK) == 0) << "binary should be executable"; + const auto perms = tests::fs::status(bin).permissions(); + const auto execPerms = tests::fs::perms::owner_exec + | tests::fs::perms::group_exec + | tests::fs::perms::others_exec; + expect((perms & execPerms) != tests::fs::perms::none) + << "binary should be executable"; }; } diff --git a/tests/helpers.hpp b/tests/helpers.hpp index 63f92991c..ea469b00e 100644 --- a/tests/helpers.hpp +++ b/tests/helpers.hpp @@ -14,11 +14,11 @@ #include #include #include +#include #include #include #include #include -#include #include #include @@ -142,9 +142,10 @@ struct TempDir { const auto ticks = std::chrono::duration_cast(epoch) .count(); + const auto random = + static_cast(std::random_device{}()); std::ostringstream oss; - oss << "cabin-test-" << static_cast(::getpid()) << '-' - << ticks; + oss << "cabin-test-" << random << '-' << ticks; return fs::temp_directory_path() / oss.str(); }()) { fs::create_directories(path); From 56ceb8948545ebd0d887a975b5e0b4e68bdb1512 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:21:22 -0400 Subject: [PATCH 09/11] fix: tidy fix --- src/BuildConfig.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BuildConfig.cc b/src/BuildConfig.cc index 0b90dcb8a..b95f7bec1 100644 --- a/src/BuildConfig.cc +++ b/src/BuildConfig.cc @@ -584,7 +584,7 @@ Result BuildConfig::configureBuild() { ldFlags = combineFlags({ ldOthers, libDirs }); libs = joinFlags(project.compilerOpts.ldFlags.libs); - std::vector sourceFilePaths = listSourceFilePaths(srcDir); + const std::vector sourceFilePaths = listSourceFilePaths(srcDir); for (const fs::path& sourceFilePath : sourceFilePaths) { if (sourceFilePath != mainSource && isMainSource(sourceFilePath)) { Diag::warn( @@ -666,7 +666,7 @@ Result BuildConfig::configureBuild() { const fs::path integrationTestDir = project.rootPath / "tests"; if (fs::exists(integrationTestDir)) { - std::vector integrationSources = + const std::vector integrationSources = listSourceFilePaths(integrationTestDir); for (const fs::path& sourceFilePath : integrationSources) { if (auto maybeTarget = From c55411d5936bd3ea826c961e6d19b5526ea74a49 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:01:09 -0400 Subject: [PATCH 10/11] fix: aggregate compile_commands.json --- src/BuildConfig.cc | 81 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/BuildConfig.cc b/src/BuildConfig.cc index b95f7bec1..870c2560c 100644 --- a/src/BuildConfig.cc +++ b/src/BuildConfig.cc @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -690,15 +691,66 @@ Result BuildConfig::configureBuild() { } static Result generateCompdb(const fs::path& outDir) { - Command compdbCmd("ninja"); - compdbCmd.addArg("-C").addArg(outDir.string()); - compdbCmd.addArg("-t").addArg("compdb"); - compdbCmd.addArg("cxx_compile"); - const CommandOutput output = Try(compdbCmd.output()); - Ensure(output.exitStatus.success(), "ninja -t compdb {}", output.exitStatus); - - std::ofstream compdbFile(outDir / "compile_commands.json"); - compdbFile << output.stdOut; + const fs::path cabinOutRoot = outDir.parent_path(); + + std::vector buildDirs{ outDir }; + if (fs::exists(cabinOutRoot) && fs::is_directory(cabinOutRoot)) { + for (const auto& entry : fs::directory_iterator(cabinOutRoot)) { + if (!entry.is_directory()) { + continue; + } + const fs::path buildDir = entry.path(); + if (fs::exists(buildDir / "build.ninja")) { + buildDirs.push_back(buildDir); + } + } + } + + std::sort(buildDirs.begin(), buildDirs.end()); + buildDirs.erase(std::unique(buildDirs.begin(), buildDirs.end()), + buildDirs.end()); + + std::map, nlohmann::json> entries; + + for (const fs::path& buildDir : buildDirs) { + if (!fs::exists(buildDir / "build.ninja")) { + continue; + } + + Command compdbCmd("ninja"); + compdbCmd.addArg("-C").addArg(buildDir.string()); + compdbCmd.addArg("-t").addArg("compdb"); + compdbCmd.addArg("cxx_compile"); + const CommandOutput output = Try(compdbCmd.output()); + Ensure(output.exitStatus.success(), "ninja -t compdb {}", + output.exitStatus); + + nlohmann::json json; + try { + json = nlohmann::json::parse(output.stdOut); + } catch (const nlohmann::json::parse_error& e) { + Bail("failed to parse ninja -t compdb output: {}", e.what()); + } + Ensure(json.is_array(), "invalid compdb output"); + for (auto& entry : json) { + const auto directory = entry.value("directory", std::string_view{}); + const auto file = entry.value("file", std::string_view{}); + if (!directory.empty() && !file.empty()) { + entries[std::make_pair(std::string(directory), std::string(file))] = + entry; + } + } + } + + nlohmann::json combined = nlohmann::json::array(); + for (auto& [_, entry] : entries) { + combined.push_back(std::move(entry)); + } + + fs::create_directories(cabinOutRoot); + std::ofstream aggregateFile(cabinOutRoot / "compile_commands.json"); + aggregateFile << combined.dump(2) << '\n'; + return Ok(); } @@ -721,6 +773,15 @@ Result emitNinja(const Manifest& manifest, } Try(generateCompdb(config.outBasePath)); + if (buildProfile != BuildProfile::Test) { + const fs::path testsDir = manifest.path.parent_path() / "tests"; + if (fs::exists(testsDir) && fs::is_directory(testsDir)) { + (void)Try(emitNinja(manifest, BuildProfile::Test, + /*includeDevDeps=*/true, + /*enableCoverage=*/false)); + } + } + return Ok(config); } @@ -729,7 +790,7 @@ Result emitCompdb(const Manifest& manifest, const bool includeDevDeps) { auto config = Try(emitNinja(manifest, buildProfile, includeDevDeps, /*enableCoverage=*/false)); - return Ok(config.outBasePath.string()); + return Ok(config.outBasePath.parent_path().string()); } static Command makeNinjaCommand(const bool forDryRun) { From 958eff2407021218a34d478f9aad01cf7f9d4f72 Mon Sep 17 00:00:00 2001 From: Ken Matsui <26405363+ken-matsui@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:31:07 -0400 Subject: [PATCH 11/11] fix: tidy fix --- src/BuildConfig.cc | 12 ++++++------ tests/build.cc | 2 +- tests/fmt.cc | 14 +++++--------- tests/init.cc | 4 ++-- tests/new.cc | 8 ++++---- tests/remove.cc | 2 +- tests/run.cc | 2 +- tests/test.cc | 22 +++++++++------------- tests/version.cc | 6 +----- 9 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/BuildConfig.cc b/src/BuildConfig.cc index 870c2560c..99bafd34a 100644 --- a/src/BuildConfig.cc +++ b/src/BuildConfig.cc @@ -699,16 +699,16 @@ static Result generateCompdb(const fs::path& outDir) { if (!entry.is_directory()) { continue; } - const fs::path buildDir = entry.path(); - if (fs::exists(buildDir / "build.ninja")) { - buildDirs.push_back(buildDir); + const fs::path& path = entry.path(); + if (fs::exists(path / "build.ninja")) { + buildDirs.push_back(path); } } } - std::sort(buildDirs.begin(), buildDirs.end()); - buildDirs.erase(std::unique(buildDirs.begin(), buildDirs.end()), - buildDirs.end()); + std::ranges::sort(buildDirs); + const auto uniqueResult = std::ranges::unique(buildDirs); + buildDirs.erase(uniqueResult.begin(), uniqueResult.end()); std::map, nlohmann::json> entries; diff --git a/tests/build.cc b/tests/build.cc index 409f38797..26ec27428 100644 --- a/tests/build.cc +++ b/tests/build.cc @@ -7,7 +7,7 @@ int main() { using boost::ut::operator""_test; "cabin build emits ninja"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "ninja_project" }, tmp.path).unwrap(); const auto project = tmp.path / "ninja_project"; diff --git a/tests/fmt.cc b/tests/fmt.cc index aeae3802d..2ac13a00b 100644 --- a/tests/fmt.cc +++ b/tests/fmt.cc @@ -5,11 +5,7 @@ #include #include -namespace { - -bool hasClangFormat() { return tests::hasCommand("clang-format"); } - -} // namespace +static bool hasClangFormat() { return tests::hasCommand("clang-format"); } int main() { using boost::ut::expect; @@ -21,7 +17,7 @@ int main() { return; } - tests::TempDir tmp; + const tests::TempDir tmp; const auto out = tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); expect(out.status.success()); @@ -43,7 +39,7 @@ int main() { return; } - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); const auto project = tmp.path / "pkg"; @@ -80,7 +76,7 @@ int main() { return; } - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); const auto project = tmp.path / "pkg"; @@ -101,7 +97,7 @@ int main() { return; } - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "pkg" }, tmp.path).unwrap(); const auto project = tests::fs::path(tmp.path) / "pkg"; diff --git a/tests/init.cc b/tests/init.cc index e95f80fc8..14486a0c4 100644 --- a/tests/init.cc +++ b/tests/init.cc @@ -8,7 +8,7 @@ int main() { using boost::ut::operator""_test; "cabin init"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; const auto project = tmp.path / "pkg"; tests::fs::create_directories(project); @@ -24,7 +24,7 @@ int main() { }; "cabin init existing"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; const auto project = tmp.path / "pkg"; tests::fs::create_directories(project); diff --git a/tests/new.cc b/tests/new.cc index f487405c9..511f0c3cc 100644 --- a/tests/new.cc +++ b/tests/new.cc @@ -8,7 +8,7 @@ int main() { using boost::ut::operator""_test; "cabin new binary"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; const auto result = tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); @@ -31,7 +31,7 @@ int main() { }; "cabin new library"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; const auto result = tests::runCabin({ "new", "--lib", "hello_world" }, tmp.path).unwrap(); @@ -53,7 +53,7 @@ int main() { }; "cabin new requires name"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; const auto result = tests::runCabin({ "new" }, tmp.path).unwrap(); expect(!result.status.success()); @@ -65,7 +65,7 @@ int main() { }; "cabin new existing"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; const auto project = tmp.path / "existing"; tests::fs::create_directories(project); diff --git a/tests/remove.cc b/tests/remove.cc index e19d6d632..7db6680cc 100644 --- a/tests/remove.cc +++ b/tests/remove.cc @@ -11,7 +11,7 @@ int main() { using boost::ut::operator""_test; "cabin remove"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "remove_test" }, tmp.path).unwrap(); const auto project = tmp.path / "remove_test"; diff --git a/tests/run.cc b/tests/run.cc index e35cc2603..803772c1e 100644 --- a/tests/run.cc +++ b/tests/run.cc @@ -10,7 +10,7 @@ int main() { using boost::ut::operator""_test; "cabin run"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); const auto project = tmp.path / "hello_world"; diff --git a/tests/test.cc b/tests/test.cc index 6fbff8e6c..95e014b97 100644 --- a/tests/test.cc +++ b/tests/test.cc @@ -6,10 +6,8 @@ #include #include -namespace { - -std::size_t countFiles(const tests::fs::path& root, - std::string_view extension) { +static std::size_t countFiles(const tests::fs::path& root, + std::string_view extension) { if (!tests::fs::exists(root)) { return 0; } @@ -22,7 +20,7 @@ std::size_t countFiles(const tests::fs::path& root, return count; } -std::string expectedTestSummary(std::string_view projectName) { +static std::string expectedTestSummary(std::string_view projectName) { return fmt::format( " Compiling {} v0.1.0 ()\n" " Finished `test` profile [unoptimized + debuginfo] target(s) in " @@ -32,14 +30,12 @@ std::string expectedTestSummary(std::string_view projectName) { projectName); } -} // namespace - int main() { using boost::ut::expect; using boost::ut::operator""_test; "cabin test basic"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "test_project" }, tmp.path).unwrap(); const auto project = tmp.path / "test_project"; @@ -83,7 +79,7 @@ int main() { }; "cabin test help"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "test_project" }, tmp.path).unwrap(); const auto project = tmp.path / "test_project"; const auto projectPath = tests::fs::weakly_canonical(project).string(); @@ -98,7 +94,7 @@ int main() { }; "cabin test coverage"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "coverage_project" }, tmp.path).unwrap(); const auto project = tmp.path / "coverage_project"; const auto projectPath = tests::fs::weakly_canonical(project).string(); @@ -139,7 +135,7 @@ int main() { }; "cabin test verbose coverage"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "verbose_project" }, tmp.path).unwrap(); const auto project = tmp.path / "verbose_project"; const auto projectPath = tests::fs::weakly_canonical(project).string(); @@ -174,7 +170,7 @@ int main() { }; "cabin test without coverage"_test = [] { - tests::TempDir tmp; + const tests::TempDir tmp; tests::runCabin({ "new", "no_coverage_project" }, tmp.path).unwrap(); const auto project = tmp.path / "no_coverage_project"; const auto projectPath = tests::fs::weakly_canonical(project).string(); @@ -205,6 +201,6 @@ int main() { expect(sanitizedErr == expectedTestSummary("no_coverage_project")); const auto outDir = project / "cabin-out" / "test"; - expect(countFiles(outDir, ".gcda") == 0u); + expect(countFiles(outDir, ".gcda") == 0U); }; } diff --git a/tests/version.cc b/tests/version.cc index e955008e6..c2646c340 100644 --- a/tests/version.cc +++ b/tests/version.cc @@ -7,15 +7,11 @@ #include #include -namespace { - -std::string readVersion() { +static std::string readVersion() { auto manifest = cabin::Manifest::tryParse().unwrap(); return manifest.package.version.toString(); } -} // namespace - int main() { using boost::ut::expect; using boost::ut::operator""_test;