diff --git a/.github/workflows/dunitest.yml b/.github/workflows/dunitest.yml new file mode 100644 index 0000000000..d339803205 --- /dev/null +++ b/.github/workflows/dunitest.yml @@ -0,0 +1,43 @@ +name: Dunitest x86_64 + +on: + workflow_dispatch: + push: + branches: ["master", "feat-*", "fix-*"] + pull_request: + branches: ["master", "feat-*", "fix-*"] + +env: + ARCH: x86_64 + HOME: /root + RUSTUP_DIST_SERVER: "https://rsproxy.cn" + RUSTUP_UPDATE_ROOT: "https://rsproxy.cn/rustup" + +jobs: + dunitest: + name: Dunitest + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: dragonos/dragonos-dev:v1.22 + options: --privileged -v /dev:/dev + steps: + - name: Checkout DragonOS code + uses: actions/checkout@v4 + + - name: Change source + run: | + find . -type f \( -name "*.toml" -o -name "Makefile" \) -exec sed -i 's/git\.mirrors\.dragonos\.org\.cn/github\.com/g' {} + + + - name: Build DragonOS + shell: bash -ileo pipefail {0} + run: | + make clean + make -j$(nproc) all + + - name: Run dunitest + shell: bash -ileo pipefail {0} + env: + DISK_SAVE_MODE: "1" + run: | + make test-dunit diff --git a/Makefile b/Makefile index 56f58ef6d0..732905c039 100644 --- a/Makefile +++ b/Makefile @@ -288,6 +288,22 @@ test-syscall: prepare_rootfs_manifest exit $$status; \ } +test-dunit: prepare_rootfs_manifest + @echo "构建运行并执行dunitest测试" + $(MAKE) all -j $(NPROCS) + @if [ "$(DISK_SAVE_MODE)" = "1" ]; then \ + echo "磁盘节省模式启用,正在清理用户程序构建缓存..."; \ + $(DADK) -f $(ROOT_PATH)/dadk-manifest.generated.toml user clean --level in-src -w $(ROOT_PATH); \ + fi + SKIP_GRUB=1 $(MAKE) write_diskimage || exit 1 + $(MAKE) qemu-nographic AUTO_TEST=dunit DUNITEST_DIR=/opt/tests/dunitest & + sleep 5 + @bash user/apps/tests/dunitest/monitor_test_results.sh + +test-dunit-local: + @echo "构建并执行 dunitest 本地测试" + $(MAKE) -C user/apps/tests/dunitest test-local -j $(NPROCS) + fmt: check_arch @echo "格式化代码" FMT_CHECK=$(FMT_CHECK) $(MAKE) fmt -C kernel @@ -342,6 +358,9 @@ help: @echo " make clean-docs - 清理文档" @echo " make test-syscall - 构建运行并执行syscall测试" @echo " - 可通过DISK_SAVE_MODE=1启用磁盘节省模式" + @echo " make test-dunit - 构建运行并执行dunitest测试" + @echo " - 可通过DISK_SAVE_MODE=1启用磁盘节省模式" + @echo " make test-dunit-local - 本地运行 dunitest 测例" @echo "" @echo "环境变量:" @echo " DISK_SAVE_MODE=1 - 启用磁盘节省模式,在写入磁盘镜像前清理构建缓存" diff --git a/docs/kernel/ktest/dunitest.rst b/docs/kernel/ktest/dunitest.rst new file mode 100644 index 0000000000..48301a552c --- /dev/null +++ b/docs/kernel/ktest/dunitest.rst @@ -0,0 +1,137 @@ +============================== +dunitest 用户态测试框架 +============================== + +dunitest 是 DragonOS 的用户态单元测试框架,用于运行基于 Google Test 的 C++ 测例并输出结构化报告。 + + +现状 +==== + +- runner 自动发现 ``bin/`` 下的可执行文件并执行 +- 默认超时 60 秒,可通过 ``--timeout-sec`` 覆盖 +- 测例过滤顺序:white list -> block list -> --pattern +- 汇总统计默认按 gtest 的测试用例数聚合(不是按测试程序个数) +- 测试失败或超时时,runner 返回非 0 + +目录与职责 +========== + +.. code-block:: text + + user/apps/tests/dunitest/ + ├── runner/ # Rust 测试运行器 + ├── suites/ # 测试源码(按 suite 分目录) + ├── bin/ # 编译产物(runner 自动发现) + ├── whitelist.txt # 默认白名单 + ├── scripts/run_tests.sh # 系统内执行入口 + └── Makefile + +关键规则 +======== + +1. 源码位置:``suites//*.cc`` +2. 编译输出:``bin//_test`` +3. runner 用例名:``/``(自动去掉 ``_test`` 后缀) + +示例: + +- 二进制:``bin/demo/gtest_demo_test`` +- 用例名:``demo/gtest_demo`` +- white list 条目:``demo/gtest_demo`` + +如何新增测例 +============ + +推荐:普通功能测试优先放在 ``normal`` suite +--------------------------------------- + +- 普通/通用功能测例建议统一放在 ``suites/normal/`` 下,便于集中维护 +- 示例:``suites/normal/capability.cc`` +- 在 ``whitelist.txt`` 中对应条目写作:``normal/capability`` + +1. 新增 gtest 源码 +----------------- + +新增文件,例如: + +.. code-block:: text + + suites/normal/capability.cc + +2. 把 suite 加入 Makefile +------------------------- + +编辑 ``user/apps/tests/dunitest/Makefile`` 的 ``SUITES``: + +.. code-block:: makefile + + # 如果新增了目录,需要在这里加入 + SUITES = demo normal + +3. 构建并运行(支持并行) +---------------------- + +在仓库根目录: + +.. code-block:: bash + + make test-dunit-local + +或在 dunitest 目录: + +.. code-block:: bash + + make run -j$(nproc) + +构建日志示例: + +.. code-block:: text + + 编译测例: suites/normal/capability.cc -> bin/normal/capability_test + +4. 加入 white list +-------------------------- + +编辑 ``whitelist.txt``,每行一个用例名: + +.. code-block:: text + + demo/gtest_demo + normal/capability + +Runner 参数 +=========== + +.. code-block:: text + + dunitest-runner [OPTIONS] + + --bin-dir 测试二进制目录(默认: bin) + --timeout-sec 单测超时秒数(默认: 60) + --whitelist white list 路径(默认: whitelist.txt) + --blocklist block list 路径(默认: blocklist.txt) + --results-dir 报告目录(默认: results) + --list 仅列出测例,不执行 + --verbose 详细输出 + --pattern 名称子串过滤(可多次指定) + +报告输出 +======== + +执行后在 ``results/`` 下生成: + +- ``test_report.txt``:文本报告 +- ``summary.json``:JSON 汇总 +- ``failed_cases.txt``:失败/超时列表 +- ``.log``:单测日志 + +终端汇总口径说明: + +- ``总测试数/通过/失败/跳过`` 按 gtest 用例数统计 +- 当某个程序没有产出 gtest 统计信息时,才按测试程序粒度回退统计 + +安装说明 +======== + +在 ``user/apps/tests/dunitest/`` 目录下执行 ``make install`` 即可 diff --git a/docs/kernel/ktest/index.rst b/docs/kernel/ktest/index.rst index 84ab47ef5c..d48a060714 100644 --- a/docs/kernel/ktest/index.rst +++ b/docs/kernel/ktest/index.rst @@ -16,5 +16,6 @@ .. toctree:: :maxdepth: 1 + dunitest gvisor_syscall_test diff --git a/tools/run-qemu.sh b/tools/run-qemu.sh index 2ac20e7268..80446eb5cc 100755 --- a/tools/run-qemu.sh +++ b/tools/run-qemu.sh @@ -162,9 +162,12 @@ KERNEL_CMDLINE=" " # 自动测试选项,支持的选项: # - none: 不进行自动测试 # - syscall: 进行gvisor系统调用测试 +# - dunit: 进行dunitest测试 AUTO_TEST=${AUTO_TEST:=none} # gvisor测试目录 SYSCALL_TEST_DIR=${SYSCALL_TEST_DIR:=/opt/tests/gvisor} +# dunitest测试目录 +DUNITEST_DIR=${DUNITEST_DIR:=/opt/tests/dunitest} BIOS_TYPE="" #这个变量为true则使用virtio磁盘 @@ -260,7 +263,7 @@ while true;do setup_kernel_init_program() { if [ ${ARCH} == "x86_64" ]; then - KERNEL_CMDLINE+=" init=/bin/busybox init AUTO_TEST=${AUTO_TEST} SYSCALL_TEST_DIR=${SYSCALL_TEST_DIR} " + KERNEL_CMDLINE+=" init=/bin/busybox init AUTO_TEST=${AUTO_TEST} SYSCALL_TEST_DIR=${SYSCALL_TEST_DIR} DUNITEST_DIR=${DUNITEST_DIR} " # KERNEL_CMDLINE+=" init=/bin/dragonreach " elif [ ${ARCH} == "riscv64" ]; then KERNEL_CMDLINE+=" init=/bin/riscv_rust_init " diff --git a/user/apps/tests/dunitest/.gitignore b/user/apps/tests/dunitest/.gitignore new file mode 100644 index 0000000000..aac030526e --- /dev/null +++ b/user/apps/tests/dunitest/.gitignore @@ -0,0 +1,5 @@ +/target +/results +/install +/third_party +/bin diff --git a/user/apps/tests/dunitest/Makefile b/user/apps/tests/dunitest/Makefile new file mode 100644 index 0000000000..732e930886 --- /dev/null +++ b/user/apps/tests/dunitest/Makefile @@ -0,0 +1,90 @@ +ifdef DADK_CURRENT_BUILD_DIR +INSTALL_DIR = $(DADK_CURRENT_BUILD_DIR) +else +INSTALL_DIR = ./install +endif + +TOOLCHAIN = +nightly-2025-08-10-x86_64-unknown-linux-gnu +RUSTFLAGS += +ifeq ($(ARCH), x86_64) + export RUST_TARGET = x86_64-unknown-linux-musl +else ifeq ($(ARCH), riscv64) + export RUST_TARGET = riscv64gc-unknown-linux-gnu +else + export RUST_TARGET = x86_64-unknown-linux-musl +endif + +RUNNER_DIR = runner +RUNNER_BIN = $(RUNNER_DIR)/target/$(RUST_TARGET)/release/dunitest-runner +RESULTS_DIR = results +BIN_DIR = bin + +# 要编译的测试套件目录列表(suites/ 下的子目录名) +SUITES = demo normal + +GTEST_ROOT = third_party/googletest +GTEST_REPO = https://git.mirrors.dragonos.org.cn/DragonOS-Community/googletest +GTEST_TAG = v1.17.0 +GTEST_SRC = $(GTEST_ROOT)/googletest/src/gtest-all.cc +GTEST_TMP_ROOT = $(GTEST_ROOT).tmp +CXX ?= g++ +CXXFLAGS ?= -Wall -O2 -std=c++17 -pthread +GTEST_INCLUDES = -I$(GTEST_ROOT)/googletest -I$(GTEST_ROOT)/googletest/include +SUITE_SRCS = $(foreach suite,$(SUITES),$(wildcard suites/$(suite)/*.cc)) +TEST_BINS = $(patsubst suites/%.cc,$(BIN_DIR)/%_test,$(SUITE_SRCS)) + +.PHONY: all build build-suites run list test-local install clean fmt fetch-gtest + +all: build install + +build: build-suites + @echo "构建 dunitest runner..." + @cd $(RUNNER_DIR) && RUSTFLAGS=$(RUSTFLAGS) cargo $(TOOLCHAIN) build --target $(RUST_TARGET) --release + +fetch-gtest: $(GTEST_SRC) + +$(GTEST_SRC): + @echo "拉取 googletest ($(GTEST_TAG))..." + @rm -rf "$(GTEST_TMP_ROOT)" + @mkdir -p "$(dir $(GTEST_ROOT))" + @if [ -d "$(GTEST_ROOT)" ]; then \ + echo "检测到残缺的 $(GTEST_ROOT),重新拉取..."; \ + rm -rf "$(GTEST_ROOT)"; \ + fi + @git clone --depth 1 --branch "$(GTEST_TAG)" "$(GTEST_REPO)" "$(GTEST_TMP_ROOT)" + @mv "$(GTEST_TMP_ROOT)" "$(GTEST_ROOT)" + @test -f "$(GTEST_SRC)" + +build-suites: $(TEST_BINS) + +$(BIN_DIR)/%_test: suites/%.cc $(GTEST_SRC) + @mkdir -p "$(dir $@)" + @echo "编译测例: $< -> $@" + @$(CXX) $(CXXFLAGS) $(GTEST_INCLUDES) "$<" $(GTEST_SRC) -o "$@" + +run: build + @$(RUNNER_BIN) --bin-dir $(BIN_DIR) --results-dir $(RESULTS_DIR) + +list: build + @$(RUNNER_BIN) --bin-dir $(BIN_DIR) --list + +test-local: build + @$(RUNNER_BIN) --bin-dir $(BIN_DIR) --results-dir $(RESULTS_DIR) --verbose + +install: build + @echo "安装 dunitest 到 $(INSTALL_DIR)" + @mkdir -p $(INSTALL_DIR) + @cp -f $(RUNNER_BIN) $(INSTALL_DIR)/dunitest-runner + @chmod +x $(INSTALL_DIR)/dunitest-runner + @cp -rf $(BIN_DIR) $(INSTALL_DIR)/ + @cp -f whitelist.txt $(INSTALL_DIR)/whitelist.txt + @cp -f scripts/run_tests.sh $(INSTALL_DIR)/run_tests.sh + @chmod +x $(INSTALL_DIR)/run_tests.sh + @echo "安装完成" + +clean: + @rm -rf $(RESULTS_DIR) install $(BIN_DIR) + @cd $(RUNNER_DIR) && cargo clean + +fmt: + @cd $(RUNNER_DIR) && cargo fmt diff --git a/user/apps/tests/dunitest/README.md b/user/apps/tests/dunitest/README.md new file mode 100644 index 0000000000..078e9d1153 --- /dev/null +++ b/user/apps/tests/dunitest/README.md @@ -0,0 +1,68 @@ +# dunitest + +DragonOS 用户态单元测试框架 + +## 当前行为(现状) + +- 测例源码放在 `suites//*.cc` +- 构建输出固定为 `bin//_test` +- runner 递归扫描 `bin/`,生成用例名时会去掉 `_test` 后缀 + 例如:`bin/demo/gtest_demo_test` -> `demo/gtest_demo` +- 默认超时是 60 秒,可通过 `--timeout-sec` 覆盖 +- 过滤规则:`whitelist` -> `blocklist` -> `pattern` + +## 快速使用 + +在仓库根目录执行: + +```bash +make test-dunit-local +``` + +或在 `dunitest` 目录执行: + +```bash +make run +``` + +## 如何添加新测例 + +1. 新增源码:`suites//.cc` +2. 如果有新创建的目录,在 `Makefile` 的 `SUITES` 里加入 `` +3. 执行 `make test-local + +构建时将自动生成: + +```text +编译测例: suites//.cc -> bin//_test +``` + +如果要通过白名单启用该测例,在 `whitelist.txt` 里写: + +```text +/ +``` + +## Runner 参数 + +```text +dunitest-runner [OPTIONS] + + --bin-dir 测试二进制目录(默认: bin) + --timeout-sec 单测超时秒数(默认: 60) + --whitelist 白名单路径(默认: whitelist.txt) + --blocklist 黑名单路径(默认: blocklist.txt) + --results-dir 报告目录(默认: results) + --list 仅列出测例 + --verbose 详细输出 + --pattern 名称子串过滤(可多次) +``` + +## 安装内容 + +`make install` 后安装运行所需文件: + +- `dunitest-runner` +- `run_tests.sh` +- `whitelist.txt` +- `bin/` diff --git a/user/apps/tests/dunitest/monitor_test_results.sh b/user/apps/tests/dunitest/monitor_test_results.sh new file mode 100644 index 0000000000..23eb236af5 --- /dev/null +++ b/user/apps/tests/dunitest/monitor_test_results.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ -z "${ROOT_PATH:-}" ]; then + echo "[dunit-monitor] 错误: ROOT_PATH 环境变量未设置" + exit 1 +fi + +if [ -z "${VMSTATE_DIR:-}" ]; then + echo "[dunit-monitor] 错误: VMSTATE_DIR 环境变量未设置" + exit 1 +fi + +SERIAL_FILE="serial_opt.txt" +BOOT_TIMEOUT=300 +TEST_START_TIMEOUT=600 +TEST_TIMEOUT=1800 +IDLE_TIMEOUT=120 + +get_qemu_pid() { + if [ -f "${VMSTATE_DIR}/pid" ]; then + cat "${VMSTATE_DIR}/pid" + else + echo "" + fi +} + +cleanup() { + local qemu_pid + qemu_pid="$(get_qemu_pid)" + + if [ -n "$qemu_pid" ] && sudo kill -0 "$qemu_pid" 2>/dev/null; then + echo "[dunit-monitor] 终止QEMU进程 (PID: $qemu_pid)" + sudo kill -TERM "$qemu_pid" 2>/dev/null || true + sleep 3 + if sudo kill -0 "$qemu_pid" 2>/dev/null; then + echo "[dunit-monitor] 强制终止QEMU进程 (PID: $qemu_pid)" + sudo kill -9 "$qemu_pid" 2>/dev/null || true + fi + fi + + rm -f "${VMSTATE_DIR}/pid" + pkill -P $$ 2>/dev/null || true + stty sane 2>/dev/null || true +} + +check_qemu_alive() { + local qemu_pid + qemu_pid="$(get_qemu_pid)" + [ -n "$qemu_pid" ] && sudo kill -0 "$qemu_pid" 2>/dev/null +} + +echo "[dunit-monitor] 等待QEMU进程启动..." +qemu_pid="" +for _ in $(seq 1 30); do + qemu_pid="$(get_qemu_pid)" + if [ -n "$qemu_pid" ]; then + break + fi + sleep 1 +done + +if [ -z "$qemu_pid" ]; then + echo "[dunit-monitor] 错误: 未发现QEMU PID文件" + exit 1 +fi + +if ! sudo kill -0 "$qemu_pid" 2>/dev/null; then + echo "[dunit-monitor] 错误: QEMU进程不存在 (PID: $qemu_pid)" + exit 1 +fi + +trap 'cleanup; exit 1' INT TERM + +start_time="$(date +%s)" +last_output_time="$start_time" +last_line_count=0 +boot_completed=false +test_started=false + +echo "[dunit-monitor] 开始监控 dunitest (QEMU PID: $qemu_pid)" +echo "[dunit-monitor] 超时配置: 开机${BOOT_TIMEOUT}s, 测试启动${TEST_START_TIMEOUT}s, 总超时${TEST_TIMEOUT}s" + +while true; do + now="$(date +%s)" + elapsed="$((now - start_time))" + + if [ "$elapsed" -gt "$TEST_TIMEOUT" ]; then + echo "[dunit-monitor] 错误: 测试总超时 (${TEST_TIMEOUT}秒)" + cleanup + exit 1 + fi + + if ! check_qemu_alive; then + echo "[dunit-monitor] 错误: QEMU进程已退出" + cleanup + exit 1 + fi + + serial_exists=false + if [ -f "$SERIAL_FILE" ]; then + serial_exists=true + current_line_count="$(wc -l < "$SERIAL_FILE" 2>/dev/null || echo 0)" + file_mtime="$(stat -c %Y "$SERIAL_FILE" 2>/dev/null || echo 0)" + + if [ "$current_line_count" -gt "$last_line_count" ] || [ "$file_mtime" -gt "$((now - 5))" ]; then + last_output_time="$now" + last_line_count="$current_line_count" + fi + fi + + if [ "$serial_exists" = true ] && [ "$((now - last_output_time))" -gt "$IDLE_TIMEOUT" ]; then + echo "[dunit-monitor] 错误: 超过 ${IDLE_TIMEOUT} 秒无新输出" + cleanup + exit 1 + fi + + if [ "$boot_completed" = false ]; then + if [ -f "$SERIAL_FILE" ] && grep -aq "\[rcS\] Running system init script..." "$SERIAL_FILE" 2>/dev/null; then + boot_completed=true + echo "[dunit-monitor] 系统启动完成,等待dunitest启动..." + elif [ "$elapsed" -gt "$BOOT_TIMEOUT" ]; then + echo "[dunit-monitor] 错误: 系统启动超时 (${BOOT_TIMEOUT}秒)" + cleanup + exit 1 + fi + fi + + if [ "$boot_completed" = true ] && [ "$test_started" = false ]; then + if [ -f "$SERIAL_FILE" ] && grep -aq "\[dunit\] start running tests..." "$SERIAL_FILE" 2>/dev/null; then + test_started=true + echo "[dunit-monitor] dunitest已启动" + elif [ "$elapsed" -gt "$TEST_START_TIMEOUT" ]; then + echo "[dunit-monitor] 错误: dunitest启动超时 (${TEST_START_TIMEOUT}秒)" + cleanup + exit 1 + fi + fi + + if [ -f "$SERIAL_FILE" ] && grep -aq "\[dunit\] 测试完成, status=" "$SERIAL_FILE" 2>/dev/null; then + status_line="$(grep -a "\[dunit\] 测试完成, status=" "$SERIAL_FILE" | tail -n 1 || true)" + status_value="$(echo "$status_line" | sed -n 's/.*status=\([0-9][0-9]*\).*/\1/p')" + if [ "$status_value" = "0" ]; then + echo "[dunit-monitor] dunitest测试通过" + cleanup + exit 0 + fi + echo "[dunit-monitor] dunitest测试失败, status=${status_value:-unknown}" + cleanup + exit 1 + fi + + sleep 10 +done diff --git a/user/apps/tests/dunitest/runner/.gitignore b/user/apps/tests/dunitest/runner/.gitignore new file mode 100644 index 0000000000..ea8c4bf7f3 --- /dev/null +++ b/user/apps/tests/dunitest/runner/.gitignore @@ -0,0 +1 @@ +/target diff --git a/user/apps/tests/dunitest/runner/Cargo.lock b/user/apps/tests/dunitest/runner/Cargo.lock new file mode 100644 index 0000000000..30c964bb4c --- /dev/null +++ b/user/apps/tests/dunitest/runner/Cargo.lock @@ -0,0 +1,256 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "dunitest-runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/user/apps/tests/dunitest/runner/Cargo.toml b/user/apps/tests/dunitest/runner/Cargo.toml new file mode 100644 index 0000000000..aaeae9a584 --- /dev/null +++ b/user/apps/tests/dunitest/runner/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunitest-runner" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "dunitest-runner" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/user/apps/tests/dunitest/runner/src/executor.rs b/user/apps/tests/dunitest/runner/src/executor.rs new file mode 100644 index 0000000000..14ddf619b4 --- /dev/null +++ b/user/apps/tests/dunitest/runner/src/executor.rs @@ -0,0 +1,286 @@ +use crate::manifest::TestSpec; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + thread, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CaseStatus { + Passed, + Failed, + Skipped, + Timeout, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CaseResult { + pub name: String, + pub status: CaseStatus, + pub duration_ms: u128, + pub exit_code: Option, + pub message: String, + pub log_file: String, + pub gtest_total: usize, + pub gtest_passed: usize, + pub gtest_failed: usize, + pub gtest_skipped: usize, +} + +pub fn run_test(spec: &TestSpec, results_dir: &Path, verbose: bool) -> Result { + let start = Instant::now(); + let log_path = results_dir.join(format!("{}.log", sanitize_case_name(&spec.name))); + let mut precheck_log = + File::create(&log_path).with_context(|| format!("创建日志文件失败: {}", log_path.display()))?; + + if let Some(msg) = validate_gtest_binary(spec)? { + writeln!(precheck_log, "{}", msg).with_context(|| "写入日志失败")?; + let result = CaseResult { + name: spec.name.clone(), + status: CaseStatus::Failed, + duration_ms: start.elapsed().as_millis(), + exit_code: None, + message: "非 gtest 测例,已拒绝执行".to_string(), + log_file: log_path.display().to_string(), + gtest_total: 0, + gtest_passed: 0, + gtest_failed: 0, + gtest_skipped: 0, + }; + return Ok(result); + } + drop(precheck_log); + + let log_file = + File::create(&log_path).with_context(|| format!("创建日志文件失败: {}", log_path.display()))?; + let shared_log = Arc::new(Mutex::new(log_file)); + + let mut cmd = Command::new(&spec.path); + cmd.args(&spec.args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().with_context(|| { + format!( + "启动测试进程失败: name={}, path={}", + spec.name.as_str(), + spec.path.as_str() + ) + })?; + let stdout_pipe = child + .stdout + .take() + .with_context(|| "获取子进程 stdout 管道失败")?; + let stderr_pipe = child + .stderr + .take() + .with_context(|| "获取子进程 stderr 管道失败")?; + + let stdout_thread = spawn_pipe_forwarder(stdout_pipe, Arc::clone(&shared_log), false); + let stderr_thread = spawn_pipe_forwarder(stderr_pipe, Arc::clone(&shared_log), true); + + let timeout = Duration::from_secs(spec.timeout_sec); + let status = loop { + if let Some(status) = child.try_wait().with_context(|| "等待测试进程状态失败")? { + break status; + } + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + let result = CaseResult { + name: spec.name.clone(), + status: CaseStatus::Timeout, + duration_ms: start.elapsed().as_millis(), + exit_code: None, + message: format!("超时: {} 秒", spec.timeout_sec), + log_file: log_path.display().to_string(), + gtest_total: 0, + gtest_passed: 0, + gtest_failed: 0, + gtest_skipped: 0, + }; + return Ok(result); + } + thread::sleep(Duration::from_millis(50)); + }; + join_pipe_forwarder(stdout_thread)?; + join_pipe_forwarder(stderr_thread)?; + + let elapsed = start.elapsed().as_millis(); + let code = status.code(); + let passed = status.success(); + let gtest = parse_gtest_counts(&log_path).unwrap_or((0, 0, 0, 0)); + + let result = if passed { + CaseResult { + name: spec.name.clone(), + status: CaseStatus::Passed, + duration_ms: elapsed, + exit_code: code, + message: "ok".to_string(), + log_file: log_path.display().to_string(), + gtest_total: gtest.0, + gtest_passed: gtest.1, + gtest_failed: gtest.2, + gtest_skipped: gtest.3, + } + } else { + CaseResult { + name: spec.name.clone(), + status: CaseStatus::Failed, + duration_ms: elapsed, + exit_code: code, + message: format!( + "gtest 返回失败退出码: {:?}", + code + ), + log_file: log_path.display().to_string(), + gtest_total: gtest.0, + gtest_passed: gtest.1, + gtest_failed: gtest.2, + gtest_skipped: gtest.3, + } + }; + + let _ = verbose; + + Ok(result) +} + +fn parse_gtest_counts(log_path: &Path) -> Result<(usize, usize, usize, usize)> { + let content = std::fs::read_to_string(log_path) + .with_context(|| format!("读取 gtest 日志失败: {}", log_path.display()))?; + + let mut total = 0usize; + let mut passed = 0usize; + let mut failed = 0usize; + let mut skipped = 0usize; + + for line in content.lines() { + let s = line.trim(); + + if s.starts_with("[==========]") && s.contains(" tests from ") && s.contains(" ran.") { + if let Some(v) = first_usize_token(s) { + total = v; + } + continue; + } + + if let Some(v) = parse_summary_counter_line(s, "[ PASSED ]") { + passed = v; + continue; + } + if let Some(v) = parse_summary_counter_line(s, "[ FAILED ]") { + failed = v; + continue; + } + if let Some(v) = parse_summary_counter_line(s, "[ SKIPPED ]") { + skipped = v; + continue; + } + } + + Ok((total, passed, failed, skipped)) +} + +fn parse_summary_counter_line(line: &str, prefix: &str) -> Option { + if !line.starts_with(prefix) { + return None; + } + first_usize_token(line) +} + +fn first_usize_token(s: &str) -> Option { + for token in s.split_whitespace() { + if let Ok(v) = token.parse::() { + return Some(v); + } + } + None +} + +fn sanitize_case_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +pub fn abs_or_join(base: &Path, path: &str) -> PathBuf { + let p = PathBuf::from(path); + if p.is_absolute() { p } else { base.join(p) } +} + +fn validate_gtest_binary(spec: &TestSpec) -> Result> { + let output = Command::new(&spec.path) + .arg("--gtest_help") + .output() + .with_context(|| format!("预检查 gtest 失败: {}", spec.path))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let merged = format!("{}\n{}", stdout, stderr); + let marker = "This program contains tests written using Google Test"; + + if output.status.success() && merged.contains(marker) { + return Ok(None); + } + + Ok(Some(format!( + "dunitest: '{}' 不是有效 gtest 测例,缺少 gtest 标识文本。\n预检查退出码: {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", + spec.path, + output.status.code(), + stdout, + stderr + ))) +} + +fn spawn_pipe_forwarder(mut reader: R, shared_log: Arc>, is_stderr: bool) -> JoinHandle> +where + R: Read + Send + 'static, +{ + thread::spawn(move || -> Result<()> { + let mut buf = [0_u8; 4096]; + loop { + let n = reader.read(&mut buf).with_context(|| "读取子进程管道失败")?; + if n == 0 { + break; + } + + if is_stderr { + let mut term = std::io::stderr().lock(); + term.write_all(&buf[..n]).with_context(|| "写入终端 stderr 失败")?; + term.flush().with_context(|| "刷新终端 stderr 失败")?; + } else { + let mut term = std::io::stdout().lock(); + term.write_all(&buf[..n]).with_context(|| "写入终端 stdout 失败")?; + term.flush().with_context(|| "刷新终端 stdout 失败")?; + } + + let mut log = shared_log.lock().map_err(|_| anyhow::anyhow!("日志锁已损坏"))?; + log.write_all(&buf[..n]).with_context(|| "写入日志文件失败")?; + log.flush().with_context(|| "刷新日志文件失败")?; + } + Ok(()) + }) +} + +fn join_pipe_forwarder(handle: JoinHandle>) -> Result<()> { + match handle.join() { + Ok(inner) => inner, + Err(_) => anyhow::bail!("输出转发线程发生 panic"), + } +} diff --git a/user/apps/tests/dunitest/runner/src/main.rs b/user/apps/tests/dunitest/runner/src/main.rs new file mode 100644 index 0000000000..2d1eb03902 --- /dev/null +++ b/user/apps/tests/dunitest/runner/src/main.rs @@ -0,0 +1,183 @@ +mod executor; +mod manifest; +mod report; + +use anyhow::{Context, Result}; +use clap::Parser; +use executor::{abs_or_join, run_test, CaseResult, CaseStatus}; +use manifest::{Manifest, TestSpec}; +use report::{build_summary, write_reports}; +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Parser)] +#[command(name = "dunitest-runner", version, about = "DragonOS dunitest runner (M1)")] +struct Cli { + #[arg(long, default_value = "bin")] + bin_dir: PathBuf, + #[arg(long, default_value_t = 60)] + timeout_sec: u64, + #[arg(long, default_value = "whitelist.txt")] + whitelist: PathBuf, + #[arg(long, default_value = "blocklist.txt")] + blocklist: PathBuf, + #[arg(long, default_value = "results")] + results_dir: PathBuf, + #[arg(long)] + list: bool, + #[arg(long)] + verbose: bool, + #[arg(long = "pattern")] + patterns: Vec, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let cwd = std::env::current_dir().with_context(|| "获取当前目录失败")?; + + let bin_dir = abs_or_join(&cwd, &cli.bin_dir.display().to_string()); + let whitelist_path = abs_or_join(&cwd, &cli.whitelist.display().to_string()); + let blocklist_path = abs_or_join(&cwd, &cli.blocklist.display().to_string()); + let results_dir = abs_or_join(&cwd, &cli.results_dir.display().to_string()); + + let manifest = Manifest::discover(&bin_dir, cli.timeout_sec)?; + let whitelist = read_name_list(&whitelist_path); + let blocklist = read_name_list(&blocklist_path); + + if cli.list { + for t in &manifest.tests { + if select_test( + t, + &cli.patterns, + whitelist.as_ref(), + blocklist.as_ref(), + ) + .is_none() + { + println!("{}", t.name); + } + } + return Ok(()); + } + + fs::create_dir_all(&results_dir) + .with_context(|| format!("创建结果目录失败: {}", results_dir.display()))?; + + let mut results: Vec = Vec::new(); + for test in &manifest.tests { + if let Some(skip_reason) = select_test( + test, + &cli.patterns, + whitelist.as_ref(), + blocklist.as_ref(), + ) { + let skipped = CaseResult { + name: test.name.clone(), + status: CaseStatus::Skipped, + duration_ms: 0, + exit_code: None, + message: skip_reason, + log_file: String::new(), + gtest_total: 0, + gtest_passed: 0, + gtest_failed: 0, + gtest_skipped: 0, + }; + println!( + "[RUNNER] SKIP: {} reason={}", + skipped.name, skipped.message + ); + results.push(skipped); + continue; + } + + println!("[RUNNER] START: {}", test.name); + + let mut t = test.clone(); + t.path = abs_or_join(&cwd, &t.path).display().to_string(); + + let one = run_test(&t, &results_dir, cli.verbose)?; + println!( + "[RUNNER] END: {} status={} duration_ms={} log={}", + one.name, + case_status_text(&one.status), + one.duration_ms, + one.log_file + ); + results.push(one); + } + + let summary = build_summary(results); + write_reports(&results_dir, &summary)?; + show_summary(&summary, &results_dir); + + if summary.failed > 0 || summary.timeout > 0 { + std::process::exit(1); + } + + Ok(()) +} + +fn select_test( + test: &TestSpec, + patterns: &[String], + whitelist: Option<&HashSet>, + blocklist: Option<&HashSet>, +) -> Option { + if let Some(wl) = whitelist { + if !wl.contains(&test.name) { + return Some("not_in_whitelist".to_string()); + } + } + if let Some(bl) = blocklist { + if bl.contains(&test.name) { + return Some("matched_blocklist".to_string()); + } + } + if !patterns.is_empty() && !patterns.iter().any(|p| test.name.contains(p)) { + return Some("pattern_mismatch".to_string()); + } + None +} + +fn read_name_list(path: &Path) -> Option> { + if !path.exists() { + return None; + } + let Ok(content) = fs::read_to_string(path) else { + return None; + }; + let mut set = HashSet::new(); + for line in content.lines() { + let s = line.trim(); + if !s.is_empty() && !s.starts_with('#') { + set.insert(s.to_string()); + } + } + Some(set) +} + +fn show_summary(summary: &report::Summary, results_dir: &Path) { + println!(); + println!("================ dunitest ================"); + println!("总测试数: {}", summary.total); + println!("通过: {}", summary.passed); + println!("失败: {}", summary.failed); + println!("跳过: {}", summary.skipped); + println!("超时: {}", summary.timeout); + println!("成功率: {:.2}%", summary.success_rate); + println!("报告目录: {}", results_dir.display()); + println!("=========================================="); +} + +fn case_status_text(status: &CaseStatus) -> &'static str { + match status { + CaseStatus::Passed => "PASSED", + CaseStatus::Failed => "FAILED", + CaseStatus::Skipped => "SKIPPED", + CaseStatus::Timeout => "TIMEOUT", + } +} diff --git a/user/apps/tests/dunitest/runner/src/manifest.rs b/user/apps/tests/dunitest/runner/src/manifest.rs new file mode 100644 index 0000000000..c8091d3360 --- /dev/null +++ b/user/apps/tests/dunitest/runner/src/manifest.rs @@ -0,0 +1,109 @@ +use anyhow::{Context, Result}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[derive(Debug)] +pub struct Manifest { + pub tests: Vec, +} + +#[derive(Debug, Clone)] +pub struct TestSpec { + pub name: String, + pub path: String, + pub args: Vec, + pub timeout_sec: u64, +} + +impl Manifest { + pub fn discover(bin_dir: &Path, default_timeout_sec: u64) -> Result { + if !bin_dir.is_dir() { + anyhow::bail!("测试二进制目录不存在: {}", bin_dir.display()); + } + let mut tests = Vec::new(); + discover_in_dir(bin_dir, bin_dir, default_timeout_sec, &mut tests)?; + + tests.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Manifest { tests }) + } +} + +fn discover_in_dir( + root: &Path, + current: &Path, + default_timeout_sec: u64, + tests: &mut Vec, +) -> Result<()> { + let entries = fs::read_dir(current) + .with_context(|| format!("读取测试二进制目录失败: {}", current.display()))?; + + for entry in entries { + let entry = entry.with_context(|| "读取目录项失败")?; + let path = entry.path(); + if path.is_dir() { + discover_in_dir(root, &path, default_timeout_sec, tests)?; + continue; + } + if !path.is_file() || !is_executable(&path)? { + continue; + } + + let Some(rel) = to_relative_slash(root, &path) else { + continue; + }; + + tests.push(TestSpec { + name: normalize_case_name(&rel), + path: path.display().to_string(), + args: Vec::new(), + timeout_sec: default_timeout_sec, + }); + } + Ok(()) +} + +fn to_relative_slash(root: &Path, path: &Path) -> Option { + let rel = path.strip_prefix(root).ok()?; + let mut s = String::new(); + for (idx, part) in rel.components().enumerate() { + if idx > 0 { + s.push('/'); + } + s.push_str(part.as_os_str().to_str()?); + } + Some(s) +} + +fn normalize_case_name(relative_path: &str) -> String { + let path = PathBuf::from(relative_path); + let mut parts: Vec = path + .iter() + .map(|seg| seg.to_string_lossy().to_string()) + .collect(); + + if let Some(last) = parts.last_mut() { + if let Some(stripped) = last.strip_suffix("_test") { + *last = stripped.to_string(); + } + } + parts.join("/") +} + +fn is_executable(path: &Path) -> Result { + #[cfg(unix)] + { + let metadata = fs::metadata(path) + .with_context(|| format!("读取文件属性失败: {}", path.display()))?; + Ok(metadata.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(true) + } +} diff --git a/user/apps/tests/dunitest/runner/src/report.rs b/user/apps/tests/dunitest/runner/src/report.rs new file mode 100644 index 0000000000..1a32100baf --- /dev/null +++ b/user/apps/tests/dunitest/runner/src/report.rs @@ -0,0 +1,148 @@ +use crate::executor::{CaseResult, CaseStatus}; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::{ + fs::{self, File}, + io::Write, + path::Path, +}; + +#[derive(Debug, Serialize)] +pub struct Summary { + pub total: usize, + pub passed: usize, + pub failed: usize, + pub skipped: usize, + pub timeout: usize, + pub success_rate: f64, + pub cases: Vec, +} + +pub fn build_summary(cases: Vec) -> Summary { + let mut passed = 0usize; + let mut failed = 0usize; + let mut skipped = 0usize; + let mut timeout = 0usize; + let mut gtest_total = 0usize; + let mut gtest_passed = 0usize; + let mut gtest_failed = 0usize; + let mut gtest_skipped = 0usize; + + for c in &cases { + gtest_total += c.gtest_total; + gtest_passed += c.gtest_passed; + gtest_failed += c.gtest_failed; + gtest_skipped += c.gtest_skipped; + + match c.status { + CaseStatus::Passed => { + if c.gtest_total == 0 { + passed += 1; + } + } + CaseStatus::Failed => { + if c.gtest_total == 0 { + failed += 1; + } + } + CaseStatus::Skipped => { + if c.gtest_total == 0 { + skipped += 1; + } + } + CaseStatus::Timeout => { + if c.gtest_total == 0 { + timeout += 1; + } + } + } + } + + if gtest_total > 0 { + passed = gtest_passed; + failed = gtest_failed; + skipped = gtest_skipped; + timeout = 0; + } + + let total = if gtest_total > 0 { + gtest_total + } else { + cases.len() + }; + let success_rate = if total == 0 { + 0.0 + } else { + (passed as f64) * 100.0 / (total as f64) + }; + + Summary { + total, + passed, + failed, + skipped, + timeout, + success_rate, + cases, + } +} + +pub fn write_reports(results_dir: &Path, summary: &Summary) -> Result<()> { + fs::create_dir_all(results_dir) + .with_context(|| format!("创建结果目录失败: {}", results_dir.display()))?; + + write_text_report(results_dir, summary)?; + write_json_report(results_dir, summary)?; + write_failed_cases(results_dir, summary)?; + + Ok(()) +} + +fn write_text_report(results_dir: &Path, summary: &Summary) -> Result<()> { + let report = results_dir.join("test_report.txt"); + let mut f = + File::create(&report).with_context(|| format!("创建报告文件失败: {}", report.display()))?; + + writeln!(f, "dunitest 报告")?; + writeln!(f, "==========================")?; + writeln!(f, "总测试数: {}", summary.total)?; + writeln!(f, "通过: {}", summary.passed)?; + writeln!(f, "失败: {}", summary.failed)?; + writeln!(f, "跳过: {}", summary.skipped)?; + writeln!(f, "超时: {}", summary.timeout)?; + writeln!(f, "成功率: {:.2}%", summary.success_rate)?; + writeln!(f)?; + writeln!(f, "失败/超时列表:")?; + + for c in &summary.cases { + match c.status { + CaseStatus::Failed | CaseStatus::Timeout => { + writeln!(f, "- {}: {}", c.name, c.message)?; + } + _ => {} + } + } + Ok(()) +} + +fn write_json_report(results_dir: &Path, summary: &Summary) -> Result<()> { + let file = results_dir.join("summary.json"); + let content = serde_json::to_string_pretty(summary).with_context(|| "序列化 summary.json 失败")?; + fs::write(&file, content).with_context(|| format!("写入失败: {}", file.display()))?; + Ok(()) +} + +fn write_failed_cases(results_dir: &Path, summary: &Summary) -> Result<()> { + let file = results_dir.join("failed_cases.txt"); + let mut f = + File::create(&file).with_context(|| format!("创建文件失败: {}", file.display()))?; + for c in &summary.cases { + match c.status { + CaseStatus::Failed | CaseStatus::Timeout => { + writeln!(f, "{}", c.name)?; + } + _ => {} + } + } + Ok(()) +} diff --git a/user/apps/tests/dunitest/scripts/run_tests.sh b/user/apps/tests/dunitest/scripts/run_tests.sh new file mode 100644 index 0000000000..f9282248c9 --- /dev/null +++ b/user/apps/tests/dunitest/scripts/run_tests.sh @@ -0,0 +1,19 @@ +#!/bin/busybox sh + +set -u + +SCRIPT_DIR="$(cd -- "$(dirname "$0")" && pwd)" +if [ -x "$SCRIPT_DIR/dunitest-runner" ] && [ -d "$SCRIPT_DIR/bin" ]; then + BASE_DIR="$SCRIPT_DIR" +else + BASE_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +fi +RUNNER="$BASE_DIR/dunitest-runner" +BIN_DIR="$BASE_DIR/bin" +RESULTS="$BASE_DIR/results" + +echo "[dunit] start running tests..." +"$RUNNER" --bin-dir "$BIN_DIR" --results-dir "$RESULTS" +status=$? +echo "[dunit] 测试完成, status=$status" +exit $status diff --git a/user/apps/tests/dunitest/suites/demo/gtest_demo.cc b/user/apps/tests/dunitest/suites/demo/gtest_demo.cc new file mode 100644 index 0000000000..3bfc5254b7 --- /dev/null +++ b/user/apps/tests/dunitest/suites/demo/gtest_demo.cc @@ -0,0 +1,18 @@ +#include + +TEST(DemoSuite, BasicArithmetic) { + int lhs = 1 + 1; + int rhs = 2; + EXPECT_EQ(lhs, rhs); +} + +TEST(DemoSuite, StringCompare) { + const char* actual = "dragonos"; + const char* expected = "dragonos"; + EXPECT_STREQ(actual, expected); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/user/apps/tests/dunitest/suites/normal/cap_common.h b/user/apps/tests/dunitest/suites/normal/cap_common.h new file mode 100644 index 0000000000..8aa1c50563 --- /dev/null +++ b/user/apps/tests/dunitest/suites/normal/cap_common.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +#define _LINUX_CAPABILITY_VERSION_1 0x19980330u +#define _LINUX_CAPABILITY_VERSION_2 0x20071026u +#define _LINUX_CAPABILITY_VERSION_3 0x20080522u + +#define _LINUX_CAPABILITY_U32S_1 1 +#define _LINUX_CAPABILITY_U32S_2 2 +#define _LINUX_CAPABILITY_U32S_3 2 + +typedef struct { + uint32_t version; + int32_t pid; +} cap_user_header_t; + +typedef struct { + uint32_t effective; + uint32_t permitted; + uint32_t inheritable; +} cap_user_data_t; + +static inline uint64_t cap_words_to_u64(uint32_t lo, uint32_t hi) { + return ((uint64_t)hi << 32) | (uint64_t)lo; +} + +static inline uint64_t cap_effective_u64(const cap_user_data_t in[2]) { + return cap_words_to_u64(in[0].effective, in[1].effective); +} + +static inline uint64_t cap_permitted_u64(const cap_user_data_t in[2]) { + return cap_words_to_u64(in[0].permitted, in[1].permitted); +} + +static inline uint64_t cap_inheritable_u64(const cap_user_data_t in[2]) { + return cap_words_to_u64(in[0].inheritable, in[1].inheritable); +} + +static inline int capget_errno(uint32_t version, int32_t pid, cap_user_data_t* data) { + cap_user_header_t hdr = {.version = version, .pid = pid}; + int ret = syscall(SYS_capget, &hdr, data); + if (ret == -1) { + return errno; + } + return 0; +} + +static inline int capset_errno(uint32_t version, int32_t pid, cap_user_data_t* data) { + cap_user_header_t hdr = {.version = version, .pid = pid}; + int ret = syscall(SYS_capset, &hdr, data); + if (ret == -1) { + return errno; + } + return 0; +} + +static inline void fill_caps_v3(uint64_t e, uint64_t p, uint64_t i, cap_user_data_t out[2]) { + out[0].effective = (uint32_t)(e & 0xFFFFFFFFu); + out[0].permitted = (uint32_t)(p & 0xFFFFFFFFu); + out[0].inheritable = (uint32_t)(i & 0xFFFFFFFFu); + out[1].effective = (uint32_t)((e >> 32) & 0xFFFFFFFFu); + out[1].permitted = (uint32_t)((p >> 32) & 0xFFFFFFFFu); + out[1].inheritable = (uint32_t)((i >> 32) & 0xFFFFFFFFu); +} diff --git a/user/apps/tests/dunitest/suites/normal/capability.cc b/user/apps/tests/dunitest/suites/normal/capability.cc new file mode 100644 index 0000000000..25788acda2 --- /dev/null +++ b/user/apps/tests/dunitest/suites/normal/capability.cc @@ -0,0 +1,172 @@ +#include + +#include +#include +#include + +#include "cap_common.h" + +static void expect_capset_eperm_after_drop(uint64_t next_effective, uint64_t next_permitted, + uint64_t next_inheritable) { + pid_t child = fork(); + ASSERT_GE(child, 0) << "fork failed: errno=" << errno << " (" << strerror(errno) << ")"; + if (child == 0) { + cap_user_data_t zero[2]; + fill_caps_v3(0, 0, 0, zero); + int drop_errno = capset_errno(_LINUX_CAPABILITY_VERSION_3, 0, zero); + if (drop_errno != 0) { + _exit(2); + } + + cap_user_data_t next[2]; + fill_caps_v3(next_effective, next_permitted, next_inheritable, next); + int set_errno = capset_errno(_LINUX_CAPABILITY_VERSION_3, 0, next); + _exit(set_errno == EPERM ? 0 : 3); + } + + int status = 0; + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); +} + +TEST(CapGet, CurrentPidVersionV1V2V3) { + cap_user_data_t data_v1[_LINUX_CAPABILITY_U32S_1] = {}; + EXPECT_EQ(0, capget_errno(_LINUX_CAPABILITY_VERSION_1, 0, data_v1)); + + cap_user_data_t data_v2[_LINUX_CAPABILITY_U32S_2] = {}; + EXPECT_EQ(0, capget_errno(_LINUX_CAPABILITY_VERSION_2, 0, data_v2)); + + cap_user_data_t data_v3[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(0, capget_errno(_LINUX_CAPABILITY_VERSION_3, 0, data_v3)); +} + +TEST(CapGet, InvalidVersionProbe) { + cap_user_header_t hdr = {.version = 0xDEADBEEFu, .pid = 0}; + int ret = syscall(SYS_capget, &hdr, nullptr); + EXPECT_EQ(0, ret) << "errno=" << errno << " (" << strerror(errno) << ")"; + EXPECT_EQ(_LINUX_CAPABILITY_VERSION_3, hdr.version); +} + +TEST(CapGet, InvalidVersionWithData) { + cap_user_data_t data[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(EINVAL, capget_errno(0xCAFEBABEu, 0, data)); +} + +TEST(CapGet, NegativePid) { + cap_user_data_t data[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(EINVAL, capget_errno(_LINUX_CAPABILITY_VERSION_3, -1, data)); +} + +TEST(CapGet, NullDataptrWithValidVersion) { + cap_user_header_t hdr = {.version = _LINUX_CAPABILITY_VERSION_3, .pid = 0}; + errno = 0; + int ret = syscall(SYS_capget, &hdr, nullptr); + int saved_errno = errno; + bool ok = (ret == 0) || (ret == -1 && saved_errno == EINVAL); + EXPECT_TRUE(ok) << "ret=" << ret << ", errno=" << saved_errno << " (" << strerror(saved_errno) + << ")"; +} + +TEST(CapGet, PidNotExist) { + cap_user_data_t data[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(ESRCH, capget_errno(_LINUX_CAPABILITY_VERSION_3, 999999, data)); +} + +TEST(CapGet, NonZeroPidReturnsTargetCred) { + pid_t child = fork(); + ASSERT_GE(child, 0) << "fork failed: errno=" << errno << " (" << strerror(errno) << ")"; + + if (child == 0) { + cap_user_data_t zeros[2]; + fill_caps_v3(0, 0, 0, zeros); + cap_user_header_t hdr = {.version = _LINUX_CAPABILITY_VERSION_3, .pid = 0}; + if (syscall(SYS_capset, &hdr, zeros) != 0) { + _exit(1); + } + sleep(2); + _exit(0); + } + + sleep(1); + cap_user_header_t hdr = {.version = _LINUX_CAPABILITY_VERSION_3, .pid = child}; + cap_user_data_t data[2] = {}; + ASSERT_EQ(0, syscall(SYS_capget, &hdr, data)) + << "capget(pid=" << child << ") failed: errno=" << errno << " (" << strerror(errno) + << ")"; + + EXPECT_EQ(0u, data[0].effective); + EXPECT_EQ(0u, data[0].permitted); + EXPECT_EQ(0u, data[0].inheritable); + EXPECT_EQ(0u, data[1].effective); + EXPECT_EQ(0u, data[1].permitted); + EXPECT_EQ(0u, data[1].inheritable); + + int status = 0; + EXPECT_EQ(child, waitpid(child, &status, 0)); +} + +TEST(CapGet, NonZeroPidBasicSuccess) { + pid_t child = fork(); + ASSERT_GE(child, 0) << "fork failed: errno=" << errno << " (" << strerror(errno) << ")"; + + if (child == 0) { + _exit(0); + } + + cap_user_header_t hdr = {.version = _LINUX_CAPABILITY_VERSION_3, .pid = child}; + cap_user_data_t data[2] = {}; + EXPECT_EQ(0, syscall(SYS_capget, &hdr, data)) + << "capget(pid=" << child << ") failed: errno=" << errno << " (" << strerror(errno) + << ")"; + + int status = 0; + EXPECT_EQ(child, waitpid(child, &status, 0)); +} + +TEST(CapSet, EffectiveMustBeSubsetOfPermitted) { + cap_user_data_t data[2]; + fill_caps_v3(0x1ull, 0x0ull, 0x0ull, data); + EXPECT_EQ(EPERM, capset_errno(_LINUX_CAPABILITY_VERSION_3, 0, data)); +} + +TEST(CapSet, VersionPaths) { + cap_user_data_t data_v1[_LINUX_CAPABILITY_U32S_1] = {}; + EXPECT_EQ(0, capset_errno(_LINUX_CAPABILITY_VERSION_1, 0, data_v1)); + + cap_user_data_t data_v2[_LINUX_CAPABILITY_U32S_2] = {}; + EXPECT_EQ(0, capset_errno(_LINUX_CAPABILITY_VERSION_2, 0, data_v2)); + + cap_user_data_t data_v3[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(0, capset_errno(_LINUX_CAPABILITY_VERSION_3, 0, data_v3)); +} + +TEST(CapSet, InvalidVersionWithData) { + cap_user_data_t data[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(EINVAL, capset_errno(0xCAFEBABEu, 0, data)); +} + +TEST(CapSet, NegativePid) { + cap_user_data_t data[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(EPERM, capset_errno(_LINUX_CAPABILITY_VERSION_3, -1, data)); +} + +TEST(CapSet, NonCurrentPid) { + cap_user_data_t data[_LINUX_CAPABILITY_U32S_3] = {}; + EXPECT_EQ(EPERM, capset_errno(_LINUX_CAPABILITY_VERSION_3, 999999, data)); +} + +TEST(CapSet, PermittedNotIncrease) { + // 子进程先降权到 pP=0,再尝试提升 pP(bit0),应触发 EPERM + expect_capset_eperm_after_drop(0, 1, 0); +} + +TEST(CapSet, InheritableBounds) { + // 子进程先降权到 pI=0,pP=0,再尝试提升 pI(bit0),应触发 EPERM + expect_capset_eperm_after_drop(0, 0, 1); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/user/apps/tests/dunitest/whitelist.txt b/user/apps/tests/dunitest/whitelist.txt new file mode 100644 index 0000000000..1dd8a54292 --- /dev/null +++ b/user/apps/tests/dunitest/whitelist.txt @@ -0,0 +1,4 @@ +# dunitest whitelist +# 格式: 相对于 bin/ 的用例名(去除可执行文件的 _test 后缀) +demo/gtest_demo +normal/capability diff --git a/user/dadk/config/all/dunitest-0.1.0.toml b/user/dadk/config/all/dunitest-0.1.0.toml new file mode 100644 index 0000000000..a9b03980b5 --- /dev/null +++ b/user/dadk/config/all/dunitest-0.1.0.toml @@ -0,0 +1,40 @@ +# 用户程序名称 +name = "dunitest" +# 版本号 +version = "0.1.0" +# 用户程序描述信息 +description = "DragonOS dunitest framework (gtest based)" +# (可选)是否只构建一次,如果为true,DADK会在构建成功后,将构建结果缓存起来,下次构建时,直接使用缓存的构建结果 +build-once = false +# (可选) 是否只安装一次,如果为true,DADK会在安装成功后,不再重复安装 +install-once = false +# 目标架构 +# 可选值:"x86_64", "aarch64", "riscv64" +target-arch = ["x86_64"] + +# 任务源 +[task-source] +# 构建类型 +# 可选值:"build-from_source", "install-from-prebuilt" +type = "build-from-source" +# 构建来源 +# "build_from_source" 可选值:"git", "local", "archive" +# "install_from_prebuilt" 可选值:"local", "archive" +source = "local" +# 路径或URL +source-path = "user/apps/tests/dunitest" + +# 构建相关信息 +[build] +# (可选)构建命令 +build-command = "make install -j $(nproc)" + +# 安装相关信息 +[install] +# (可选)安装到DragonOS的路径 +in-dragonos-path = "/opt/tests/dunitest" + +# 清除相关信息 +[clean] +# (可选)清除命令 +clean-command = "make clean" diff --git a/user/dadk/config/sets/default/dunitest-0.1.0.toml b/user/dadk/config/sets/default/dunitest-0.1.0.toml new file mode 120000 index 0000000000..cd3c14cf5f --- /dev/null +++ b/user/dadk/config/sets/default/dunitest-0.1.0.toml @@ -0,0 +1 @@ +../../all/dunitest-0.1.0.toml \ No newline at end of file diff --git a/user/sysconfig/etc/init.d/rcS b/user/sysconfig/etc/init.d/rcS index 4c4a7c2bda..84a78303ba 100755 --- a/user/sysconfig/etc/init.d/rcS +++ b/user/sysconfig/etc/init.d/rcS @@ -14,4 +14,6 @@ mount -t tmpfs tmpfs /tmp || /bin/busybox mount -t tmpfs tmpfs /tmp # 根据环境变量AUTO_TEST决定是否进行自动测试 if [ "$AUTO_TEST" = "syscall" ]; then /bin/busybox sh $SYSCALL_TEST_DIR/run_tests.sh -fi \ No newline at end of file +elif [ "$AUTO_TEST" = "dunit" ]; then + /bin/busybox sh $DUNITEST_DIR/run_tests.sh +fi