From c8864a4c1c2d4a0d2018b60c68717d1c3a6040c4 Mon Sep 17 00:00:00 2001 From: bonigo1998 Date: Fri, 25 Sep 2026 21:58:02 -0500 Subject: [PATCH] test: add automated tests and GitHub Actions --- .github/workflows/ci.yml | 35 +++++++ CMakeLists.txt | 38 +++++++- tests/file_tool_tests.cpp | 140 ++++++++++++++++++++++++++++ tests/tool_request_parser_tests.cpp | 72 ++++++++++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/file_tool_tests.cpp create mode 100644 tests/tool_request_parser_tests.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d7d92e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: C++ tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out source + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake g++ libcurl4-openssl-dev nlohmann-json3-dev + + - name: Configure + run: cmake -S . -B build -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --parallel 2 + + - name: Test + run: ctest --test-dir build --output-on-failure --no-tests=error \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 0909832..df46e31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,4 +33,40 @@ target_compile_options(agentforge PRIVATE target_link_libraries(agentforge PRIVATE CURL::libcurl nlohmann_json::nlohmann_json -) \ No newline at end of file +) + +include(CTest) + +if(BUILD_TESTING) + add_executable(tool_request_parser_tests + tests/tool_request_parser_tests.cpp + src/tool_request_parser.cpp + ) + + target_include_directories(tool_request_parser_tests PRIVATE src) + target_compile_features(tool_request_parser_tests PRIVATE cxx_std_20) + set_target_properties(tool_request_parser_tests PROPERTIES + CXX_EXTENSIONS OFF + ) + + add_test( + NAME tool_request_parser + COMMAND tool_request_parser_tests + ) + + add_executable(file_tool_tests + tests/file_tool_tests.cpp + src/file_tool.cpp + ) + + target_include_directories(file_tool_tests PRIVATE src) + target_compile_features(file_tool_tests PRIVATE cxx_std_20) + set_target_properties(file_tool_tests PROPERTIES + CXX_EXTENSIONS OFF + ) + + add_test( + NAME workspace_file_tool + COMMAND file_tool_tests + ) +endif() \ No newline at end of file diff --git a/tests/file_tool_tests.cpp b/tests/file_tool_tests.cpp new file mode 100644 index 0000000..48c87d4 --- /dev/null +++ b/tests/file_tool_tests.cpp @@ -0,0 +1,140 @@ +#include "file_tool.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; + +// Owns the test directory and restores the original working directory. +class TestWorkspace { +public: + TestWorkspace() : original_directory_(fs::current_path()) { + const fs::path base = fs::canonical(fs::temp_directory_path()); + std::random_device random; + + for (int attempt = 0; attempt < 100; ++attempt) { + root_ = base / ( + "agentforge-tests-" + std::to_string(random()) + + "-" + std::to_string(random()) + ); + + if (fs::create_directory(root_)) { + return; + } + } + + throw std::runtime_error("Could not create a test directory."); + } + + TestWorkspace(const TestWorkspace&) = delete; + TestWorkspace& operator=(const TestWorkspace&) = delete; + + ~TestWorkspace() { + std::error_code error; + fs::current_path(original_directory_, error); + fs::remove_all(root_, error); + } + + const fs::path& root() const { + return root_; + } + +private: + fs::path original_directory_; + fs::path root_; +}; + +void write_text(const fs::path& path, const std::string& text) { + std::ofstream output; + output.exceptions(std::ios::failbit | std::ios::badbit); + output.open(path); + output << text; + output.close(); +} + +void expect(bool passed, const char* description) { + if (!passed) { + throw std::runtime_error(description); + } +} + +void expect_rejected( + const FileTool& tool, + const std::string& path, + const char* expected_error) { + try { + tool.read(path); + } catch (const std::runtime_error& error) { + if (std::string(error.what()).find(expected_error) != + std::string::npos) { + return; + } + + throw std::runtime_error( + "Unexpected error for " + path + ": " + error.what()); + } + + throw std::runtime_error("Expected rejection for: " + path); +} + +} // namespace + +int main() { + try { + TestWorkspace temporary; + const fs::path workspace = temporary.root() / "workspace"; + const std::string contents = "AgentForge file test\n"; + + fs::create_directories(workspace / "nested"); + + write_text(workspace / "inside.txt", contents); + write_text(temporary.root() / "outside.txt", "Outside file\n"); + + fs::create_symlink( + workspace / "inside.txt", + workspace / "inside-link.txt"); + + fs::create_symlink( + temporary.root() / "outside.txt", + workspace / "outside-link.txt"); + + fs::current_path(workspace); + const FileTool tool; + + expect(tool.read("inside.txt") == contents, + "A workspace file should return its exact contents."); + + expect(tool.read("nested/../inside.txt") == contents, + "A normalized path inside the workspace should work."); + + expect(tool.read("inside-link.txt") == contents, + "A symlink staying inside the workspace should work."); + + expect_rejected( + tool, "missing.txt", "Could not open file"); + + expect_rejected( + tool, (workspace / "inside.txt").string(), + "must be relative"); + + expect_rejected( + tool, "../outside.txt", "outside the workspace"); + + expect_rejected( + tool, "outside-link.txt", "outside the workspace"); + + std::cout << "All file-tool checks passed.\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "File-tool test failed: " << error.what() << '\n'; + return 1; + } +} \ No newline at end of file diff --git a/tests/tool_request_parser_tests.cpp b/tests/tool_request_parser_tests.cpp new file mode 100644 index 0000000..df51ced --- /dev/null +++ b/tests/tool_request_parser_tests.cpp @@ -0,0 +1,72 @@ +#include "tool_request_parser.hpp" + +#include +#include +#include +#include +#include + +namespace { + +void expect(bool passed, const char* description) { + if (!passed) { + throw std::runtime_error(description); + } +} + +} // namespace + +int main() { + try { + ToolRequestParser parser; + + expect(!parser.parse("Hello").has_value(), + "Ordinary text should not become a command."); + + expect(!parser.parse("").has_value(), + "Empty input should not become a command."); + + expect(!parser.parse("/").has_value(), + "A slash alone should not become a command."); + + const auto read = parser.parse("/read README.md"); + expect(read && + read->name == "read" && + read->arguments == + std::vector{"README.md"}, + "/read should preserve the file path."); + + const auto ask = parser.parse( + "/askfile\tREADME.md What is this? "); + expect(ask && + ask->name == "askfile" && + ask->arguments == std::vector{ + "README.md", "What", "is", "this?"}, + "/askfile should split whitespace and preserve word order."); + + const auto clear = parser.parse("/clear"); + expect(clear && + clear->name == "clear" && + clear->arguments.empty(), + "/clear should have no arguments."); + + const auto missing_path = parser.parse("/read"); + expect(missing_path && + missing_path->name == "read" && + missing_path->arguments.empty(), + "Missing arguments should remain available for Agent validation."); + + const auto unknown = parser.parse("/unknown value"); + expect(unknown && + unknown->name == "unknown" && + unknown->arguments == + std::vector{"value"}, + "Unknown commands should remain available for Agent validation."); + + std::cout << "All parser checks passed.\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Parser test failed: " << error.what() << '\n'; + return 1; + } +}