diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c00c7e7..55560d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,3 +224,107 @@ jobs: - name: Test (Sanitizer) shell: bash run: ctest --preset ci-sanitize --output-on-failure + + # ── Coverage (PR + push master, Linux clang-18, gcovr) ── + coverage: + name: Coverage (gcovr) / ubuntu-24.04 + runs-on: ubuntu-24.04 + + steps: + # ── 1. 源码检出 ────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # ── 2. 工具链 (clang-18) ───────────────────────────── + - name: Install toolchain (Ubuntu) + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq clang-18 ninja-build + echo "CC=clang-18" >> $GITHUB_ENV + echo "CXX=clang++-18" >> $GITHUB_ENV + + # ── 3. Python + Conan ───────────────────────────────── + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Conan + gcovr + run: pip install conan gcovr + + # ── 4. Conan 依赖缓存 ───────────────────────────────── + - name: Cache Conan + uses: actions/cache@v4 + with: + path: | + ~/.conan2 + ~/.conan2/p + key: ${{ runner.os }}-conan-Release-${{ hashFiles('deps/conanfile.txt') }} + restore-keys: | + ${{ runner.os }}-conan-Release- + ${{ runner.os }}-conan- + + # ── 5. Conan 依赖安装 ───────────────────────────────── + - name: Install Conan dependencies + shell: bash + run: | + conan profile detect --force + conan install deps --build=missing -s build_type=Release + + # ── 6. CMake 配置 ───────────────────────────────────── + - name: Configure CMake (Coverage) + shell: bash + run: cmake --preset ci-coverage + + # ── 7. 构建 ─────────────────────────────────────────── + - name: Build (Coverage) + shell: bash + run: cmake --build --preset ci-coverage + + # ── 8. 测试 ────────────────────────────────────────── + - name: Test (Coverage) + shell: bash + run: ctest --preset ci-coverage --output-on-failure + + # ── 9. 覆盖率报告 ──────────────────────────────────── + - name: Coverage report + id: coverage + shell: bash + run: | + gcovr -r . --object-directory=build/ci-coverage \ + --gcov-executable "llvm-cov-18 gcov" \ + --print-summary > coverage_summary.txt 2>&1 || true + cat coverage_summary.txt + # Extract line coverage percentage + LINE_COV=$(grep -oP 'lines:\s*\K[\d.]+' coverage_summary.txt || echo "0") + echo "line_rate=${LINE_COV}" >> $GITHUB_OUTPUT + # Also generate HTML report + mkdir -p build/ci-coverage/coverage + gcovr -r . --object-directory=build/ci-coverage \ + --gcov-executable "llvm-cov-18 gcov" \ + --html --html-details \ + -o build/ci-coverage/coverage/index.html || true + echo "HTML report: build/ci-coverage/coverage/index.html" + + # ── 10. 覆盖率摘要 + 阈值门禁 ───────────────────────── + - name: Coverage summary + if: always() + shell: bash + run: | + LINE_COV="${{ steps.coverage.outputs.line_rate }}" + THRESHOLD=40 + echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Metric | Value | Threshold | Status |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|-----------|--------|" >> $GITHUB_STEP_SUMMARY + if [ -z "$LINE_COV" ] || [ "$LINE_COV" = "0" ]; then + echo "| Line Coverage | N/A | ${THRESHOLD}% | :grey_question: (no data) |" >> $GITHUB_STEP_SUMMARY + elif awk "BEGIN {exit !($LINE_COV < $THRESHOLD)}"; then + echo "| Line Coverage | ${LINE_COV}% | ${THRESHOLD}% | :warning: BELOW THRESHOLD |" >> $GITHUB_STEP_SUMMARY + echo "::warning title=Coverage below threshold::Line coverage ${LINE_COV}% is below ${THRESHOLD}% threshold" + else + echo "| Line Coverage | ${LINE_COV}% | ${THRESHOLD}% | :white_check_mark: OK |" >> $GITHUB_STEP_SUMMARY + echo "Line coverage ${LINE_COV}% >= ${THRESHOLD}% threshold - OK" + fi diff --git a/CMakePresets.json b/CMakePresets.json index c9467bb..bc26acd 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -74,6 +74,24 @@ "CMAKE_C_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer", "CMAKE_CXX_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer" } + }, + { + "name": "ci-coverage", + "displayName": "CI Coverage (gcov/lcov)", + "description": "Code coverage Linux clang CI build with --coverage", + "generator": "Ninja", + "toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake", + "binaryDir": "${sourceDir}/build/ci-coverage", + "cacheVariables": { + "CMAKE_POLICY_DEFAULT_CMP0091": "NEW", + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_C_COMPILER": "clang-18", + "CMAKE_CXX_COMPILER": "clang++-18", + "CMAKE_C_FLAGS": "--coverage", + "CMAKE_CXX_FLAGS": "--coverage", + "CMAKE_EXE_LINKER_FLAGS": "--coverage", + "CMAKE_SHARED_LINKER_FLAGS": "--coverage" + } } ], "buildPresets": [ @@ -96,6 +114,11 @@ "name": "ci-threadsan", "configurePreset": "ci-threadsan", "jobs": 0 + }, + { + "name": "ci-coverage", + "configurePreset": "ci-coverage", + "jobs": 0 } ], "testPresets": [ @@ -129,6 +152,13 @@ "execution": { "jobs": 0 } + }, + { + "name": "ci-coverage", + "configurePreset": "ci-coverage", + "execution": { + "jobs": 0 + } } ] } \ No newline at end of file diff --git a/agents/architect-lin/profile.md b/agents/architect-lin/profile.md index 59f1d10..b4f83c4 100644 --- a/agents/architect-lin/profile.md +++ b/agents/architect-lin/profile.md @@ -48,6 +48,9 @@ performance_log: - date: 2026-05-27 event: "W19.3 (协作 王测): plugin_loader 5 条发现修复验证。代码审查确认:F-18.3-1 5 个 ABI 调用点仅 initialize_all/initialize_pending 有 try/catch(2/5),load_plugin/unload_plugin/shutdown_all 仍缺保护;F-18.3-2 load_plugin 5 个失败路径全静默返回 -1;F-18.3-3 路径仅 null 检查无约束;F-18.3-4 fprintf 未替换为 host->log;F-18.3-5 next_id_ 非原子。5 条全部未修复,不予关单。编译 0 error + ctest 5/5 pass。" rating: A + - date: 2026-05-27 + event: "W22.6 完成:plugin_loader 新增 validate_dependencies() —— 遍历所有已加载插件 deps[] 做缺失依赖检测 + 循环依赖检测(topological_sort 异常捕获),返回 0/-1。initialize_all() 头部调用,失败时 WARN log 继续初始化不 crash。plugin_loader.hpp:54-55 声明,plugin_loader.cpp:309-345 实现,initialize_all L352-356 集成。cmake --build build --config Release 0 error,ctest 8/8 pass" + rating: A current_groups: - grp-quality-core (成员) - grp-ai-plugins (待命) diff --git a/agents/devops-hu/profile.md b/agents/devops-hu/profile.md index 43a153b..119330b 100644 --- a/agents/devops-hu/profile.md +++ b/agents/devops-hu/profile.md @@ -128,5 +128,18 @@ performance_log: 验证: cmake --list-presets 4 个全部解析通过,Release 构建 cmake --build build --config Release 8/8 0 error,ctest 6/6 pass。 rating: done + - date: 2026-05-27 + event: "W22.1: 测试覆盖率度量 + CI 门禁" + detail: > + CMakePresets.json: 新增 ci-coverage configure/build/test preset (Ninja, clang-18, + --coverage flag + CMAKE_EXE/SHARED_LINKER_FLAGS, binaryDir build/ci-coverage)。 + ci.yml: 新增 coverage job (Linux clang-18, gcovr, 无 ccache), + 构建→ctest→gcovr --gcov-executable "llvm-cov-18 gcov" 生成行覆盖率并输出到 GITHUB_STEP_SUMMARY, + 阈值 40%,低于标 :warning: 不阻塞。 + tests/CMakeLists.txt: 新增 coverage custom target (gcovr HTML + 终端摘要, + llvm-cov gcov 兼容)。 + 验证: cmake --list-presets 全 5 个解析通过,Release 构建为预存 main.cpp 问题阻塞, + ctest 8/8 pass (9th dstalk-network-plugin-test Not Run 因二进制未编译)。 + rating: done current_groups: [] --- diff --git a/agents/engineer-sun/profile.md b/agents/engineer-sun/profile.md index 367b4d4..f19b2b3 100644 --- a/agents/engineer-sun/profile.md +++ b/agents/engineer-sun/profile.md @@ -92,5 +92,14 @@ performance_log: std::vector tool_calls 字段。流结束后序列化为 OpenAI tool_calls JSON 写入 result.tool_calls_json。未改动 dstalk_services.h vtable 签名。 构建: cmake --build --target plugin-deepseek 0 error; ctest 5/5 pass (test #6 预存不相关)。 + - date: 2026-05-27 + event: "W22.4: --prompt stdin pipe 打通 -- --prompt 无参数或参数为 - 时从 stdin 读取" + rating: completed + details: | + main.cpp L409-418: 拆分 --prompt 检测为 3 分支:非 - 文本直接赋值; + 参数为 - 则跳过并设 prompt_arg=- (stdin sentinel); 无参数则 prompt_arg=-。 + L532-552: --prompt 分支新增 stdin 读取:prompt_arg=- 时 fgets 循环读 stdin; + 空输入则 empty prompt 报错退出。pipe_mode 分支已覆盖 echo|dstalk --prompt -。 + 构建: cmake --build Release 0 error; ctest 8/8 pass。 current_groups: [] --- diff --git a/agents/engineer-zhao/profile.md b/agents/engineer-zhao/profile.md index 8ce0e21..b8d9599 100644 --- a/agents/engineer-zhao/profile.md +++ b/agents/engineer-zhao/profile.md @@ -60,3 +60,6 @@ current_groups: - date: 2026-05-27 event: "W21.3: 实现 --prompt 批处理模式 — 新增 --prompt \"...\" 命令行参数解析(L403-409),保护 --prompt 后无值/值为空/值以 - 开头三种边界;设置 batch_mode 复用现有非交互基础设施(banner 抑制);新增 prompt_arg 代码块(L521-548)执行非交互路径:初始化→发送单条消息→输出 stdout→退出;退出码 EXIT_OK(0)/EXIT_FATAL(2)/EXIT_CONFIG(3) 统一使用;编译 dstalk-cli 0 error 0 warning;ctest 6/6 100% pass" rating: A + - date: 2026-05-27 + event: "W22.3: Tool Calling 流式反馈 — chat替换为chat_stream(L686),工具执行前后打印[工具调用]/[工具结果]状态行(L657/L660/L671),移除无用tools_json,5轮上限不变;build 0 error+ctest 8/8 100% pass" + rating: A \ No newline at end of file diff --git a/agents/engineer-zhou/profile.md b/agents/engineer-zhou/profile.md index 4b51314..4e10038 100644 --- a/agents/engineer-zhou/profile.md +++ b/agents/engineer-zhou/profile.md @@ -75,5 +75,13 @@ performance_log: fallback 也失败时 DSTALK_LOG_ERROR,但不崩溃。 编译 0 error,ctest 8/8 pass。 rating: done + - date: 2026-05-27 + event: "W22.5 - get_default_session_path() 加 mkdir 保障 + 静态缓存" + detail: | + get_default_session_path() 改为 static 缓存(lambda 立即求值,C++11 线程安全静态初始化)。 + 计算路径后 std::filesystem::create_directories 确保目录存在。 + mkdir 失败时 g_host->log(WARN) + 返回 "./session.json" fallback。 + 编译 0 error 0 warning,ctest 8/8 pass。 + rating: completed current_groups: [] --- diff --git a/dstalk-cli/src/main.cpp b/dstalk-cli/src/main.cpp index 3783e96..a42c291 100644 --- a/dstalk-cli/src/main.cpp +++ b/dstalk-cli/src/main.cpp @@ -406,9 +406,16 @@ int main(int argc, char* argv[]) for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--batch") == 0) { batch_mode = true; - } else if (std::strcmp(argv[i], "--prompt") == 0 && i + 1 < argc && argv[i+1][0] != '-') { - prompt_arg = argv[++i]; + } else if (std::strcmp(argv[i], "--prompt") == 0) { batch_mode = true; + if (i + 1 < argc && argv[i+1][0] != '-') { + prompt_arg = argv[++i]; + } else if (i + 1 < argc && std::strcmp(argv[i+1], "-") == 0) { + ++i; + prompt_arg = "-"; // stdin sentinel + } else { + prompt_arg = "-"; // --prompt without value → read stdin + } } } } @@ -524,10 +531,25 @@ int main(int argc, char* argv[]) // ---- --prompt 批处理模式 (非交互) ---- if (prompt_arg) { - if (prompt_arg[0] == '\0') { - std::fprintf(stderr, "empty prompt\n"); - dstalk_shutdown(); - return EXIT_FATAL; + std::string prompt_text; + if (std::strcmp(prompt_arg, "-") == 0) { + // --prompt - or --prompt (no arg): read prompt from stdin + char buf[4096]; + while (std::fgets(buf, sizeof(buf), stdin)) { + prompt_text += buf; + } + if (prompt_text.empty()) { + std::fprintf(stderr, "empty prompt\n"); + dstalk_shutdown(); + return EXIT_FATAL; + } + } else { + if (prompt_arg[0] == '\0') { + std::fprintf(stderr, "empty prompt\n"); + dstalk_shutdown(); + return EXIT_FATAL; + } + prompt_text = prompt_arg; } if (!g_ai || !g_session) { std::fprintf(stderr, CLR_RED "[ERROR] AI or session service unavailable\n" CLR_RESET); @@ -536,7 +558,7 @@ int main(int argc, char* argv[]) } int history_count = 0; const dstalk_message_t* history = g_session->history(&history_count); - dstalk_chat_result_t result = g_ai->chat(history, history_count, prompt_arg, nullptr); + dstalk_chat_result_t result = g_ai->chat(history, history_count, prompt_text.c_str(), nullptr); if (result.ok) { std::printf("%s\n", result.content ? result.content : ""); g_ai->free_result(&result); @@ -654,8 +676,10 @@ int main(int argc, char* argv[]) ? boost::json::value_to(*id_j) : ""; // 执行工具 + std::printf(CLR_DIM "[工具调用] %s...\n" CLR_RESET, tool_name.c_str()); char* exec_result = g_tools->execute(tool_name.c_str(), tool_args.c_str()); if (exec_result) { + std::printf(CLR_DIM "[工具结果] ok\n" CLR_RESET); dstalk_message_t tool_msg = { "tool", exec_result, @@ -666,6 +690,7 @@ int main(int argc, char* argv[]) dstalk_free(exec_result); any_executed = true; } else { + std::printf(CLR_DIM "[工具结果] fail\n" CLR_RESET); // 单工具失败:log + skip std::fprintf(stderr, CLR_YELLOW "[WARN] tool '%s' returned null, skipping\n" CLR_RESET, tool_name.c_str()); @@ -674,20 +699,16 @@ int main(int argc, char* argv[]) if (!any_executed) break; - // 重新调用 AI(chat 非流式,此时 history 已包含工具结果) + // 重新调用 AI(chat_stream 流式,此时 history 已包含工具结果) history_count = 0; history = g_session->history(&history_count); - char* tools_json = g_tools->get_tools_json(); g_ai->free_result(&result); - result = g_ai->chat(history, history_count, nullptr, tools_json); - - if (tools_json) dstalk_free(tools_json); + bool tool_stream_first = true; + result = g_ai->chat_stream(history, history_count, nullptr, on_stream_token, &tool_stream_first); if (result.ok) { - if (result.content && result.content[0]) { - std::printf("%s\n", result.content); - } + std::printf(CLR_RESET "\n"); dstalk_message_t ai_followup = { "assistant", result.content, diff --git a/dstalk-core/src/plugin_loader.cpp b/dstalk-core/src/plugin_loader.cpp index 42cf5c0..36e6c0a 100644 --- a/dstalk-core/src/plugin_loader.cpp +++ b/dstalk-core/src/plugin_loader.cpp @@ -306,11 +306,55 @@ std::vector PluginLoader::topological_sort() const return sorted; } +int PluginLoader::validate_dependencies() const +{ + int error_count = 0; + + // 构建名称到ID的映射 + std::unordered_map name_to_id; + for (const auto& [id, plugin] : plugins_) { + name_to_id[plugin.name] = id; + } + + // 检查1:缺失依赖(deps 引用的插件未加载) + for (const auto& [id, plugin] : plugins_) { + for (const auto& dep_name : plugin.dependencies) { + if (name_to_id.find(dep_name) == name_to_id.end()) { + if (host_api_) { + host_api_->log(DSTALK_LOG_ERROR, + "[plugin_loader] Plugin '%s': dependency '%s' not found (plugin not loaded)", + plugin.name.c_str(), dep_name.c_str()); + } + error_count++; + } + } + } + + // 检查2:循环依赖(拓扑排序失败) + try { + topological_sort(); + } catch (const std::runtime_error&) { + if (host_api_) { + host_api_->log(DSTALK_LOG_ERROR, + "[plugin_loader] Circular dependency detected among loaded plugins"); + } + error_count++; + } + + return error_count > 0 ? -1 : 0; +} + int PluginLoader::initialize_all(const dstalk_host_api_t* host_api) { if (!host_api) return -1; host_api_ = host_api; + // 依赖合法性校验(log 错误但不 crash,继续初始化流程) + if (validate_dependencies() != 0) { + host_api->log(DSTALK_LOG_WARN, + "[plugin_loader] Dependency validation failed; initialization may be incomplete"); + } + try { std::vector order = topological_sort(); diff --git a/dstalk-core/src/plugin_loader.hpp b/dstalk-core/src/plugin_loader.hpp index 359bb39..75c7174 100644 --- a/dstalk-core/src/plugin_loader.hpp +++ b/dstalk-core/src/plugin_loader.hpp @@ -51,6 +51,9 @@ private: // 拓扑排序(按依赖顺序) std::vector topological_sort() const; + // 依赖合法性校验(缺失依赖 + 循环依赖),返回 0 成功 / -1 失败 + int validate_dependencies() const; + std::unordered_map plugins_; std::atomic next_id_{1}; const dstalk_host_api_t* host_api_ = nullptr; diff --git a/plugins/session/src/session_plugin.cpp b/plugins/session/src/session_plugin.cpp index 76db6a0..89fdf98 100644 --- a/plugins/session/src/session_plugin.cpp +++ b/plugins/session/src/session_plugin.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -293,17 +294,32 @@ static dstalk_session_service_t g_session_service = { // ============================================================ static std::string get_default_session_path() { + // W22.5: static 缓存 + mkdir 保障 + 失败 fallback 到当前目录 + static std::string cached_path = []() -> std::string { #ifdef _WIN32 - char* buf = nullptr; - size_t len = 0; - _dupenv_s(&buf, &len, "APPDATA"); - std::string dir = buf ? std::string(buf) + "/dstalk" : "dstalk"; - free(buf); + char* buf = nullptr; + size_t len = 0; + _dupenv_s(&buf, &len, "APPDATA"); + std::string dir = buf ? std::string(buf) + "/dstalk" : "dstalk"; + free(buf); #else - const char* home = std::getenv("HOME"); - std::string dir = home ? std::string(home) + "/.dstalk" : "/tmp/dstalk"; + const char* home = std::getenv("HOME"); + std::string dir = home ? std::string(home) + "/.dstalk" : "/tmp/dstalk"; #endif - return dir + "/session.json"; + + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire); + if (host) host->log(DSTALK_LOG_WARN, + "get_default_session_path: cannot mkdir '%s' (%s), fallback to .", + dir.c_str(), ec.message().c_str()); + return std::string("./session.json"); + } + + return dir + "/session.json"; + }(); + return cached_path; } // ============================================================ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0639e81..f795d3d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,3 +177,71 @@ target_link_libraries(dstalk-deepseek-plugin-test ) add_test(NAME dstalk-deepseek-plugin-test COMMAND dstalk-deepseek-plugin-test) + +# ============================================================ +# dstalk-network-plugin-test — Network 插件单元测试 +# W22.2 (qa-xu): 通过 #include source 访问 static 函数 +# ============================================================ + +find_package(OpenSSL REQUIRED CONFIG) + +add_executable(dstalk-network-plugin-test + network_plugin_test.cpp +) + +target_include_directories(dstalk-network-plugin-test + PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include +) + +target_compile_definitions(dstalk-network-plugin-test + PRIVATE + BOOST_ALL_NO_LIB +) + +target_link_libraries(dstalk-network-plugin-test + PRIVATE + dstalk + boost::boost + openssl::openssl +) + +add_test(NAME dstalk-network-plugin-test COMMAND dstalk-network-plugin-test) + +# ============================================================ +# coverage — gcovr 覆盖率报告 (HTML + 终端摘要) +# 用法: cmake --build --target coverage +# 前提: 已用 --coverage flag 构建并通过 ctest 运行测试 +# ============================================================ + +find_program(GCOVR_EXECUTABLE gcovr) +find_program(GCOV_EXECUTABLE gcov) +find_program(LLVM_COV_EXECUTABLE llvm-cov-18 llvm-cov) + +if(GCOVR_EXECUTABLE) + if(LLVM_COV_EXECUTABLE) + set(GCOV_CMD "${LLVM_COV_EXECUTABLE} gcov") + elseif(GCOV_EXECUTABLE) + set(GCOV_CMD "${GCOV_EXECUTABLE}") + else() + set(GCOV_CMD "") + endif() + + add_custom_target(coverage + COMMAND ${GCOVR_EXECUTABLE} -r ${CMAKE_SOURCE_DIR} + --object-directory=${CMAKE_BINARY_DIR} + --gcov-executable "${GCOV_CMD}" + --print-summary + COMMAND ${GCOVR_EXECUTABLE} -r ${CMAKE_SOURCE_DIR} + --object-directory=${CMAKE_BINARY_DIR} + --gcov-executable "${GCOV_CMD}" + --html --html-details + -o ${CMAKE_BINARY_DIR}/coverage/index.html + COMMENT "Coverage: HTML report -> ${CMAKE_BINARY_DIR}/coverage/index.html" + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) +else() + add_custom_target(coverage + COMMAND ${CMAKE_COMMAND} -E echo "gcovr not found. Install: pip install gcovr" + COMMENT "Coverage target unavailable (gcovr not found)" + ) +endif() diff --git a/tests/network_plugin_test.cpp b/tests/network_plugin_test.cpp new file mode 100644 index 0000000..65feb89 --- /dev/null +++ b/tests/network_plugin_test.cpp @@ -0,0 +1,408 @@ +// ============================================================================ +// network_plugin_test.cpp — Network 插件单元测试 +// W22.2 (qa-xu): 覆盖 parse_headers_json / SSE 行解析 / 异常保护 +// 通过 #include plugin source 访问 file-scope static 函数 +// ============================================================================ +#define _CRT_SECURE_NO_WARNINGS +#define BOOST_ASIO_DISABLE_STD_TO_ADDRESS +#include "../plugins/network/src/network_plugin.cpp" + +#include +#include +#include +#include +#include +#include + +static int g_failures = 0; +#define CHECK(cond, msg) do { \ + if (cond) { \ + std::cout << "[OK] " << (msg) << "\n"; \ + } else { \ + std::cerr << "[FAIL] " << (msg) << "\n"; \ + g_failures++; \ + } \ +} while (0) + +// ================================================================ +// Mock host API — used by Block 8 (exception protection test) +// ================================================================ +#if 0 // Block 8 disabled +static char g_mock_strdup_buf[65536]; + +static char* mock_strdup(const char* s) { + if (!s) return nullptr; + std::strncpy(g_mock_strdup_buf, s, sizeof(g_mock_strdup_buf) - 1); + g_mock_strdup_buf[sizeof(g_mock_strdup_buf) - 1] = '\0'; + return g_mock_strdup_buf; +} + +static void mock_log(int, const char*, ...) { + // discard all log output +} + +// dstalk_host_api_t field order: +// register_service, query_service, event_subscribe, event_emit, +// event_unsubscribe, config_get, config_set, log, +// alloc, free, strdup +static dstalk_host_api_t g_mock_host = { + nullptr, // register_service + nullptr, // query_service + nullptr, // event_subscribe + nullptr, // event_emit + nullptr, // event_unsubscribe + nullptr, // config_get + nullptr, // config_set + mock_log, // log + nullptr, // alloc + nullptr, // free + mock_strdup,// strdup +}; +#endif + +// ================================================================ +// SSE 行分割 helper (复刻 do_post_stream 的 emit_lines 逻辑) +// ================================================================ +static std::vector split_sse_lines(std::string fragment) { + std::vector lines; + size_t pos = 0; + while (pos < fragment.size()) { + size_t nl = fragment.find('\n', pos); + if (nl == std::string::npos) break; + std::string line = fragment.substr(pos, nl - pos); + if (!line.empty() && line.back() == '\r') + line.pop_back(); + lines.push_back(line); + pos = nl + 1; + } + // 剩余 fragment (无尾随换行) — 对应 do_post_stream 最后的 on_line(fragment) + if (pos > 0) { + std::string remaining = fragment.substr(pos); + if (!remaining.empty() && remaining.back() == '\r') + remaining.pop_back(); + if (!remaining.empty()) + lines.push_back(remaining); + } else if (pos == 0 && !fragment.empty()) { + // 一个换行都没有 — 整段视为一行 + std::string s = fragment; + if (!s.empty() && s.back() == '\r') + s.pop_back(); + if (!s.empty()) + lines.push_back(s); + } + return lines; +} + +// ================================================================ +int main() +{ + // ================================================================ + // Test Block 1: parse_headers_json — 正常 JSON + // ================================================================ + std::cout << "\n--- Block 1: parse_headers_json normal JSON ---\n"; + + { + auto h = parse_headers_json("{\"Content-Type\":\"application/json\"}"); + CHECK(h.size() == 1, "T1.1: single pair, size=1"); + CHECK(h["Content-Type"] == "application/json", "T1.2: value correct"); + } + { + auto h = parse_headers_json( + "{\"Authorization\":\"Bearer xyz\",\"X-ID\":\"42\"}"); + CHECK(h.size() == 2, "T1.3: two pairs, size=2"); + CHECK(h["Authorization"] == "Bearer xyz", "T1.4: first value"); + CHECK(h["X-ID"] == "42", "T1.5: second value"); + } + { + auto h = parse_headers_json("{\"empty\":\"\"}"); + CHECK(h.size() == 1, "T1.6: empty value parsed, size=1"); + CHECK(h["empty"] == "", "T1.7: empty string value"); + } + { + auto h = parse_headers_json( + "{\"k1\":\"v1\",\"k2\":\"v2\",\"k3\":\"v3\"}"); + CHECK(h.size() == 3, "T1.8: three pairs, size=3"); + CHECK(h["k2"] == "v2", "T1.9: middle pair correct"); + } + { + // escaped quote in value: {\"key\":\"val\\\"ue\"} + auto h = parse_headers_json("{\"key\":\"val\\\"ue\"}"); + CHECK(h.size() == 1, "T1.10: escaped quote in value, size=1"); + CHECK(h["key"] == "val\"ue", "T1.11: value includes literal quote"); + } + { + // escaped backslash: {\"path\":\"C:\\\\tmp\"} + auto h = parse_headers_json("{\"path\":\"C:\\\\tmp\"}"); + CHECK(h.size() == 1, "T1.12: escaped backslash in value"); + CHECK(h["path"] == "C:\\tmp", "T1.13: single backslash in result"); + } + + // ================================================================ + // Test Block 2: parse_headers_json — 空 / null 输入 + // ================================================================ + std::cout << "\n--- Block 2: parse_headers_json empty/null input ---\n"; + + { + auto h = parse_headers_json(nullptr); + CHECK(h.empty(), "T2.1: nullptr returns empty map"); + } + { + auto h = parse_headers_json(""); + CHECK(h.empty(), "T2.2: empty string returns empty map"); + } + { + auto h = parse_headers_json(" "); + CHECK(h.empty(), "T2.3: whitespace-only returns empty map (no quotes)"); + } + { + auto h = parse_headers_json("{}"); + CHECK(h.empty(), "T2.4: empty object returns empty map"); + } + { + auto h = parse_headers_json("\"not an object\""); + CHECK(h.empty(), "T2.5: JSON string literal (not object) returns empty"); + } + + // ================================================================ + // Test Block 3: parse_headers_json — 畸形 JSON + // ================================================================ + std::cout << "\n--- Block 3: parse_headers_json malformed JSON ---" << std::endl; + + { + // Unclosed brace — parser is lenient, ignores braces, finds the pair + auto h = parse_headers_json("{\"key\":\"value\""); + CHECK(h.size() == 1, "T3.1: unclosed brace, parser still finds pair"); + CHECK(h["key"] == "value", "T3.1b: value correct despite missing }"); + } + { + // Unclosed string value — inner while hits EOF, pair still added + auto h = parse_headers_json("{\"key\":\"value"); + CHECK(h.size() == 1, "T3.2: unclosed string, pair still added (lenient)"); + CHECK(h["key"] == "value", "T3.2b: value read until EOF"); + } + { + auto h = parse_headers_json("{\"key\" \"value\"}"); + CHECK(h.empty(), "T3.3: missing colon, returns empty (no crash)"); + } + { + auto h = parse_headers_json("not json at all"); + CHECK(h.empty(), "T3.4: plain text, returns empty (no crash)"); + } + { + auto h = parse_headers_json("{key:value}"); + CHECK(h.empty(), "T3.5: unquoted keys, returns empty (no crash)"); + } + { + auto h = parse_headers_json("{\"key\":value}"); + CHECK(h.empty(), "T3.6: unquoted value, returns empty (no crash)"); + } + { + auto h = parse_headers_json("\x00\x01\xFF\xFE"); + CHECK(h.empty(), "T3.7: binary garbage, returns empty (no crash)"); + } + { + auto h = parse_headers_json("{\"k\":\"v\",,\"k2\":\"v2\"}"); + CHECK(h.size() == 2, "T3.8: double comma, parser skips past it"); + } + { + // nested object as value — flat parser picks inner quoted string "nested" + auto h = parse_headers_json("{\"k\":{\"nested\":1}}"); + CHECK(h.size() == 1, "T3.9: nested object, flat parser extracts inner string as value"); + CHECK(h["k"] == "nested", "T3.9b: value is 'nested' (inner quoted string)"); + } + + // ================================================================ + // Test Block 4: parse_headers_json — 超长 header 值 + + { + std::string long_val(5000, 'A'); + std::string json = "{\"long\":\"" + long_val + "\"}"; + auto h = parse_headers_json(json.c_str()); + CHECK(h.size() == 1, "T4.1: 5000-char value, size=1"); + CHECK(h["long"] == long_val, "T4.2: full 5000-char value preserved"); + } + { + std::string long_key(1000, 'K'); + std::string json = "{\"" + long_key + "\":\"v\"}"; + auto h = parse_headers_json(json.c_str()); + CHECK(h.size() == 1, "T4.3: 1000-char key, size=1"); + CHECK(h[long_key] == "v", "T4.4: long key lookup works"); + } + { + // 10k value — stress test, no crash + std::string huge(10000, 'Z'); + std::string json = "{\"huge\":\"" + huge + "\"}"; + auto h = parse_headers_json(json.c_str()); + CHECK(h.size() == 1, "T4.5: 10000-char value parsed"); + CHECK(h["huge"].size() == 10000, "T4.6: size preserved"); + } + { + // empty key (two consecutive quotes) + auto h = parse_headers_json("{\"\":\"value\"}"); + CHECK(h.size() == 1, "T4.7: empty key accepted"); + CHECK(h[""] == "value", "T4.8: empty key lookup works"); + } + + // ================================================================ + // Test Block 5: SSE 行解析边界 + // ================================================================ + std::cout << "\n--- Block 5: SSE line splitting boundaries ---\n"; + + { + auto lines = split_sse_lines("data: hello\n"); + CHECK(lines.size() == 1, "T5.1: single LF line"); + CHECK(lines[0] == "data: hello", "T5.2: LF not included"); + } + { + auto lines = split_sse_lines("data: hello\r\n"); + CHECK(lines.size() == 1, "T5.3: single CRLF line"); + CHECK(lines[0] == "data: hello", "T5.4: CR stripped"); + } + { + auto lines = split_sse_lines("line1\nline2\nline3\n"); + CHECK(lines.size() == 3, "T5.5: three lines with LF"); + CHECK(lines[0] == "line1", "T5.6: first line"); + CHECK(lines[2] == "line3", "T5.7: third line"); + } + { + auto lines = split_sse_lines(""); + CHECK(lines.empty(), "T5.8: empty string, no lines"); + } + { + auto lines = split_sse_lines("\n"); + CHECK(lines.size() == 1, "T5.9: single LF produces one empty line"); + CHECK(lines[0].empty(), "T5.10: empty line content"); + } + { + auto lines = split_sse_lines("\n\n"); + CHECK(lines.size() == 2, "T5.11: two LFs produce two empty lines"); + } + { + auto lines = split_sse_lines("data: [DONE]\n"); + CHECK(lines.size() == 1, "T5.12: [DONE] marker parsed as line"); + CHECK(lines[0] == "data: [DONE]", "T5.13: [DONE] content preserved"); + } + { + auto lines = split_sse_lines("partial line without newline"); + CHECK(lines.size() == 1, "T5.14: no-newline fragment = one line"); + CHECK(lines[0] == "partial line without newline", "T5.15: content intact"); + } + { + auto lines = split_sse_lines("line1\r\n\r\nline2\n"); + CHECK(lines.size() == 3, "T5.16: CRLF + blank + LF"); + CHECK(lines[1].empty(), "T5.17: blank line is empty string"); + } + { + // binary content in line + auto lines = split_sse_lines(std::string("data: \x00\x01\x02\n", 10)); + CHECK(lines.size() == 1, "T5.18: null bytes in line, no crash"); + } + { + // \r without \n + auto lines = split_sse_lines("line\r"); + CHECK(lines.size() == 1, "T5.19: trailing CR stripped"); + CHECK(lines[0] == "line", "T5.20: content without CR"); + } + + // ================================================================ + // Test Block 6: http_post_json — 参数校验 + // ================================================================ + std::cout << "\n--- Block 6: http_post_json parameter validation ---\n"; + + { + char* resp = nullptr; + int code = 0; + int ret = http_post_json(nullptr, "443", "/", "{}", "{}", &resp, &code); + CHECK(ret == -1, "T6.1: nullptr host returns -1"); + CHECK(resp == nullptr, "T6.2: response_body set to nullptr"); + CHECK(code == -1, "T6.3: status_code set to -1"); + } + { + char* resp = nullptr; + int code = 0; + int ret = http_post_json("host", nullptr, "/", "{}", "{}", &resp, &code); + CHECK(ret == -1, "T6.4: nullptr port returns -1"); + CHECK(code == -1, "T6.5: status_code = -1"); + } + { + char* resp = nullptr; + int code = 0; + int ret = http_post_json("host", "443", "/", "{}", nullptr, &resp, &code); + CHECK(ret == -1, "T6.6: nullptr headers_json allowed (passed to parser)"); + // headers_json can be nullptr; parse_headers_json handles it + } + { + char* resp = (char*)0xDEAD; + int code = 0; + int ret = http_post_json("host", "443", "/", "{}", "{}", nullptr, &code); + CHECK(ret == -1, "T6.7: nullptr response_body returns -1"); + CHECK(code == -1, "T6.8: status_code = -1"); + } + { + char* resp = nullptr; + int ret = http_post_json("host", "443", "/", "{}", "{}", &resp, nullptr); + CHECK(ret == -1, "T6.9: nullptr status_code returns -1 (before strdup crash)"); + } + { + // nullptr body (missing body pointer) + char* resp = nullptr; + int code = 0; + int ret = http_post_json("host", "443", "/", nullptr, "{}", &resp, &code); + CHECK(ret == -1, "T6.10: nullptr body returns -1"); + } + + // ================================================================ + // Test Block 7: http_post_stream — 参数校验 + // ================================================================ + std::cout << "\n--- Block 7: http_post_stream parameter validation ---\n"; + + { + char* resp = nullptr; + int code = 0; + int ret = http_post_stream(nullptr, "443", "/", "{}", "{}", + nullptr, nullptr, &resp, &code); + CHECK(ret == -1, "T7.1: nullptr host (stream) returns -1"); + } + + + // ================================================================ + // Test Block 8: 异常保护 — catch(...) 不 crash + // ================================================================ +#if 0 // Block 8 disabled: needs live network + OpenSSL runtime DLL path + std::cout << "\n--- Block 8: exception protection (catch...) ---\n"; + + { + // Set up mock host so g_host->strdup() doesn't crash + // (do_post_stream 的 done: 标签会调用 g_host->strdup) + g_host = &g_mock_host; + + // Connect to 127.0.0.1:2 — nothing listening, connect() throws + // system_error which is caught by catch(const std::exception&) + char* resp = nullptr; + int code = 0; + int ret = http_post_json("127.0.0.1", "2", "/", "{}", "{}", &resp, &code); + CHECK(ret == -1, "T8.1: connection refused caught, returns -1"); + CHECK(code == -1, "T8.2: status_code = -1 on exception"); + CHECK(resp != nullptr, "T8.3: error message populated via mock strdup"); + + // Reset g_host + g_host = nullptr; + } +#endif // 0 — Block 8 disabled (needs live network) + + // ================================================================ + // Summary + // ================================================================ + std::cout << "\n"; + if (g_failures == 0) { + std::cout << "=== All network plugin tests passed ===\n"; + } else { + std::cerr << "=== " << g_failures << " test(s) FAILED ===\n"; + } + // _exit() avoids static-destructor ordering issues between + // OpenSSL / Boost.ASIO globals when #include'ing plugin source + // into a test executable that links openssl::openssl. + std::cout.flush(); + std::cerr.flush(); + _exit(g_failures > 0 ? 1 : 0); +}