Skip to content

Commit 7cee310

Browse files
committed
WIP: backend-agnostic virtual HID device test harness (all platforms)
Simple open/write/read/close device-I/O smoke test plus virtual-device providers for Linux uhid (hidraw), Windows vhidmini2 (winapi), Linux raw-gadget (libusb) and macOS IOHIDUserDevice (darwin). CI wiring + two manual workflows for the privileged providers.
1 parent c3509c1 commit 7cee310

20 files changed

Lines changed: 4512 additions & 3 deletions

.github/workflows/builds.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
- name: Configure CMake
4545
run: |
4646
rm -rf build install
47-
cmake -B build/shared -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/shared -DHIDAPI_BUILD_HIDTEST=ON "-DCMAKE_C_FLAGS=${NIX_COMPILE_FLAGS}"
47+
cmake -B build/shared -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/shared -DHIDAPI_BUILD_HIDTEST=ON -DHIDAPI_WITH_TESTS=ON "-DCMAKE_C_FLAGS=${NIX_COMPILE_FLAGS}"
4848
cmake -B build/static -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/static -DBUILD_SHARED_LIBS=FALSE -DHIDAPI_BUILD_HIDTEST=ON "-DCMAKE_C_FLAGS=${NIX_COMPILE_FLAGS}"
4949
cmake -B build/framework -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/framework -DCMAKE_FRAMEWORK=ON -DHIDAPI_BUILD_HIDTEST=ON "-DCMAKE_C_FLAGS=${NIX_COMPILE_FLAGS}"
5050
- name: Build CMake Shared
@@ -56,6 +56,14 @@ jobs:
5656
- name: Build CMake Framework
5757
working-directory: build/framework
5858
run: make install
59+
- name: Run virtual-device tests (IOHIDUserDevice self-skips on hosted CI)
60+
working-directory: build/shared
61+
run: |
62+
# The macOS virtual device needs the com.apple.developer.hid.virtual.device
63+
# entitlement and interactive user consent, neither available on a hosted
64+
# runner, so DeviceIO_darwin self-skips (CTest code 77). This still
65+
# verifies the provider builds and the test runs/links.
66+
ASAN_OPTIONS=detect_leaks=0 ctest --output-on-failure
5967
- name: Check artifacts
6068
uses: andstor/file-existence-action@v2
6169
with:
@@ -122,14 +130,27 @@ jobs:
122130
- name: Configure CMake
123131
run: |
124132
rm -rf build install
125-
cmake -B build/shared -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/shared -DHIDAPI_BUILD_HIDTEST=ON "-DCMAKE_C_FLAGS=${GNU_COMPILE_FLAGS}"
133+
cmake -B build/shared -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/shared -DHIDAPI_BUILD_HIDTEST=ON -DHIDAPI_WITH_TESTS=ON "-DCMAKE_C_FLAGS=${GNU_COMPILE_FLAGS}"
126134
cmake -B build/static -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHIDAPI_ENABLE_ASAN=ON -DCMAKE_INSTALL_PREFIX=install/static -DBUILD_SHARED_LIBS=FALSE -DHIDAPI_BUILD_HIDTEST=ON "-DCMAKE_C_FLAGS=${GNU_COMPILE_FLAGS}"
127135
- name: Build CMake Shared
128136
working-directory: build/shared
129137
run: make install
130138
- name: Build CMake Static
131139
working-directory: build/static
132140
run: make install
141+
- name: Run virtual-device tests (uhid -> hidraw; raw-gadget self-skips)
142+
working-directory: build/shared
143+
run: |
144+
# uhid ships in the 'extra' modules package on the runner kernel.
145+
# Everything here is best-effort: if a virtual device can't be provided
146+
# the matching test self-skips (CTest code 77) instead of failing.
147+
sudo apt-get install -y "linux-modules-extra-$(uname -r)" || true
148+
sudo modprobe uhid || true
149+
ls -l /dev/uhid || echo "/dev/uhid not present"
150+
# Run as root so the test can create /dev/uhid and open the resulting
151+
# /dev/hidrawN. LeakSanitizer off (udev keeps allocations alive at
152+
# exit); ASan use-after-free / overflow detection stays active.
153+
sudo env "ASAN_OPTIONS=detect_leaks=0" ctest --output-on-failure
133154
- name: Check artifacts
134155
uses: andstor/file-existence-action@v2
135156
with:
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Linux libusb Virtual HID Device Test (manual)
2+
3+
# Brings up a virtual USB HID device with USB Raw Gadget (/dev/raw-gadget) on
4+
# top of dummy_hcd, then runs the backend-agnostic device-I/O test against it
5+
# through the HIDAPI libusb backend. dummy_hcd is not packaged by Ubuntu, so it
6+
# is built out-of-tree against the runner kernel headers; everything is
7+
# best-effort and the test self-skips (CTest code 77) if the modules can't be
8+
# provided. Run on demand (the push trigger is for developing this branch).
9+
10+
on:
11+
workflow_dispatch:
12+
push:
13+
branches:
14+
- virtual-device-tests
15+
16+
jobs:
17+
libusb-rawgadget:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
path: hidapisrc
23+
24+
- name: Install dependencies
25+
run: |
26+
sudo apt-get update
27+
sudo apt-get install -y libudev-dev libusb-1.0-0-dev build-essential cmake
28+
sudo apt-get install -y "linux-modules-extra-$(uname -r)" "linux-headers-$(uname -r)" || true
29+
30+
- name: Load raw_gadget and dummy_hcd
31+
run: |
32+
set -x
33+
sudo modprobe raw_gadget || true
34+
sudo modprobe dummy_hcd || true
35+
if ! lsmod | grep -q dummy_hcd; then
36+
echo "dummy_hcd not packaged; building out-of-tree..."
37+
git clone --depth 1 https://github.com/xairy/raw-gadget.git rawgadget || true
38+
if [ -d rawgadget/dummy_hcd ]; then
39+
make -C rawgadget/dummy_hcd || true
40+
sudo insmod rawgadget/dummy_hcd/dummy_hcd.ko || true
41+
fi
42+
fi
43+
sudo modprobe raw_gadget || true
44+
echo "--- modules ---"; lsmod | grep -E 'raw_gadget|dummy_hcd' || echo "(modules not fully loaded)"
45+
ls -l /dev/raw-gadget || echo "/dev/raw-gadget not present -> test will self-skip"
46+
47+
- name: Configure + build (libusb backend + tests)
48+
run: |
49+
cmake -B build -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo \
50+
-DHIDAPI_WITH_LIBUSB=ON -DHIDAPI_WITH_HIDRAW=OFF -DHIDAPI_WITH_TESTS=ON
51+
cmake --build build
52+
53+
- name: Run device-I/O test against the raw-gadget virtual device
54+
working-directory: build
55+
run: |
56+
# Root is needed to open /dev/raw-gadget and for the libusb backend to
57+
# detach the kernel driver and claim the interface.
58+
sudo ctest -R DeviceIO_libusb --output-on-failure
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
name: Windows Virtual HID Device Test (manual)
2+
3+
# Builds, self-signs and installs a modified vhidmini2 UMDF2 driver on a hosted
4+
# windows-latest runner, then runs the backend-agnostic device-I/O test against
5+
# that real virtual HID device (winapi backend). It installs a kernel driver, so
6+
# it is not part of the per-push CI matrix; run it on demand. (The push trigger
7+
# below is used while developing this branch; GitHub only exposes the "Run
8+
# workflow" button once this file is on the default branch.)
9+
10+
on:
11+
workflow_dispatch:
12+
push:
13+
branches:
14+
- virtual-device-tests
15+
16+
jobs:
17+
win-vhid:
18+
runs-on: windows-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Install WDK 26100.6584 (matches VS2022, registers VS2022 WDK VSIX)
23+
shell: pwsh
24+
run: |
25+
$url = "https://go.microsoft.com/fwlink/?linkid=2335869"
26+
Write-Host "Downloading WDK setup..."
27+
Invoke-WebRequest -Uri $url -OutFile "$env:RUNNER_TEMP\wdksetup.exe"
28+
Write-Host "Running WDK setup (silent)..."
29+
$p = Start-Process -FilePath "$env:RUNNER_TEMP\wdksetup.exe" -ArgumentList '/quiet','/norestart','/ceip','off' -Wait -PassThru
30+
Write-Host "WDK setup exit code: $($p.ExitCode)"
31+
32+
- name: Build vhidmini2 (UMDF2)
33+
shell: cmd
34+
working-directory: src/tests/windows/driver
35+
run: |
36+
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
37+
msbuild VhidminiUm.vcxproj /p:Configuration=Release /p:Platform=x64 /p:WindowsTargetPlatformVersion=10.0.26100.0 /v:minimal
38+
39+
- name: Trust the driver's test certificate
40+
shell: pwsh
41+
run: |
42+
$cer = "src/tests/windows/driver/x64/Release/VhidminiUm.cer"
43+
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
44+
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null
45+
Write-Host "Imported test cert into Root and TrustedPublisher."
46+
47+
- name: Install virtual HID device (devcon, root-enumerated)
48+
shell: pwsh
49+
run: |
50+
$devcon = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10" -Recurse -Filter devcon.exe -ErrorAction SilentlyContinue |
51+
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 -ExpandProperty FullName
52+
Write-Host "devcon: $devcon"
53+
$inf = (Resolve-Path "src/tests/windows/driver/x64/Release/VhidminiUm/VhidminiUm.inf").Path
54+
Write-Host "inf: $inf"
55+
& $devcon install $inf "root\VhidminiUm"
56+
Write-Host "devcon exit: $LASTEXITCODE"
57+
58+
- name: HID devices present (diagnostic)
59+
shell: pwsh
60+
run: |
61+
Start-Sleep -Seconds 3
62+
Get-PnpDevice -Class HIDClass -ErrorAction SilentlyContinue |
63+
Select-Object Status, FriendlyName, InstanceId | Format-Table -AutoSize
64+
65+
- name: Build HIDAPI + tests
66+
shell: pwsh
67+
run: |
68+
cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DHIDAPI_WITH_TESTS=ON
69+
cmake --build build --config Release
70+
71+
- name: Run device-I/O test against the virtual device
72+
shell: pwsh
73+
working-directory: build
74+
run: |
75+
ctest -C Release -R DeviceIO_winapi --output-on-failure
76+
77+
- name: Cleanup virtual device
78+
if: always()
79+
shell: pwsh
80+
run: |
81+
$devcon = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10" -Recurse -Filter devcon.exe -ErrorAction SilentlyContinue |
82+
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 -ExpandProperty FullName
83+
if ($devcon) { & $devcon remove "root\VhidminiUm" 2>$null }
84+
Write-Host "cleanup done"

CMakeLists.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ if(HIDAPI_ENABLE_ASAN)
7171
endif()
7272

7373
if(WIN32)
74-
# so far only Windows has tests
7574
option(HIDAPI_WITH_TESTS "Build HIDAPI (unit-)tests" ${IS_DEBUG_BUILD})
75+
elseif(CMAKE_SYSTEM_NAME MATCHES "Linux" OR APPLE)
76+
# Linux and macOS have virtual-device based tests (uhid / raw-gadget /
77+
# IOHIDUserDevice). Off by default: they need a virtual device at runtime
78+
# and otherwise self-skip.
79+
option(HIDAPI_WITH_TESTS "Build HIDAPI (unit-)tests" OFF)
7680
else()
7781
set(HIDAPI_WITH_TESTS OFF)
7882
endif()
@@ -92,6 +96,10 @@ if(HIDAPI_BUILD_HIDTEST)
9296
add_subdirectory(hidtest)
9397
endif()
9498

99+
if(HIDAPI_WITH_TESTS AND (WIN32 OR CMAKE_SYSTEM_NAME MATCHES "Linux" OR APPLE))
100+
add_subdirectory(src/tests)
101+
endif()
102+
95103
if(HIDAPI_ENABLE_ASAN)
96104
if(NOT MSVC)
97105
# MSVC doesn't recognize those options, other compilers - requiring it

src/tests/CMakeLists.txt

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Backend-generic HIDAPI (unit-)tests, run against a virtual HID device.
2+
#
3+
# The tests are written against the public HIDAPI API and the backend-agnostic
4+
# test_virtual_device interface (test_virtual_device.h), so the same test runs
5+
# against any backend for which a virtual-device provider exists. Each provider
6+
# implements the pre-recorded "scenario" protocol: the test triggers a scenario
7+
# by sending a Feature report, and the device replays a canned input report.
8+
#
9+
# Providers:
10+
# - Linux / hidraw : test_virtual_device_uhid.c (kernel /dev/uhid)
11+
# - Linux / libusb : test_virtual_device_rawgadget.c (/dev/raw-gadget + dummy_hcd)
12+
# - Windows / winapi : test_virtual_device_win.c (modified vhidmini2 UMDF2 driver)
13+
# - macOS / darwin : test_virtual_device_mac.c (IOHIDUserDevice)
14+
#
15+
# The libusb (raw-gadget), Windows and macOS virtual devices need privileged
16+
# out-of-band setup (kernel modules / a signed driver / an entitlement) that is
17+
# only performed by the dedicated CI jobs. Whenever the virtual device cannot be
18+
# created or does not enumerate, the test returns CTest's SKIP code (77) instead
19+
# of failing, so ordinary builds on any host stay green.
20+
21+
find_package(Threads REQUIRED)
22+
23+
# Define a device-I/O test <name> built from test_device_io.c + <provider>,
24+
# linked against the HIDAPI <backend>, and registered with CTest (it self-skips
25+
# via return code 77 when no virtual device is present).
26+
function(hidapi_add_vdev_test name provider backend)
27+
add_executable(${name} test_device_io.c ${provider})
28+
set_target_properties(${name} PROPERTIES
29+
C_STANDARD 11
30+
C_STANDARD_REQUIRED TRUE
31+
)
32+
target_link_libraries(${name} PRIVATE ${backend} Threads::Threads)
33+
if(HIDAPI_ENABLE_ASAN AND NOT MSVC)
34+
target_link_options(${name} PRIVATE -fsanitize=address)
35+
endif()
36+
add_test(NAME ${name} COMMAND ${name})
37+
set_tests_properties(${name} PROPERTIES
38+
SKIP_RETURN_CODE 77
39+
TIMEOUT 60
40+
)
41+
endfunction()
42+
43+
# --- Linux: hidraw backend via /dev/uhid -----------------------------------
44+
if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_hidraw)
45+
hidapi_add_vdev_test(DeviceIO_hidraw test_virtual_device_uhid.c hidapi_hidraw)
46+
endif()
47+
48+
# --- Linux: libusb backend via /dev/raw-gadget (+ dummy_hcd) ----------------
49+
# Built whenever the libusb backend is, so it keeps compiling; at runtime it
50+
# self-skips unless the raw-gadget virtual device has been set up (CI job).
51+
if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_libusb)
52+
hidapi_add_vdev_test(DeviceIO_libusb test_virtual_device_rawgadget.c hidapi_libusb)
53+
endif()
54+
55+
# --- Windows: winapi backend via a modified vhidmini2 UMDF driver -----------
56+
if(WIN32 AND TARGET hidapi_winapi)
57+
hidapi_add_vdev_test(DeviceIO_winapi test_virtual_device_win.c hidapi_winapi)
58+
# HidD_GetPreparsedData / HidP_GetCaps used by the Windows provider.
59+
target_link_libraries(DeviceIO_winapi PRIVATE hid)
60+
# Run from the directory holding the hidapi DLL so a shared build can find
61+
# it at launch (there is no rpath on Windows).
62+
set_tests_properties(DeviceIO_winapi PROPERTIES
63+
WORKING_DIRECTORY "$<TARGET_FILE_DIR:hidapi_winapi>")
64+
# With ASan (MSVC) the test exe needs the ASan runtime DLL, which lives next
65+
# to the MSVC tools; add it to PATH (CMake >= 3.22).
66+
if(HIDAPI_ENABLE_ASAN AND MSVC AND NOT CMAKE_VERSION VERSION_LESS "3.22")
67+
get_filename_component(MSVC_BUILD_TOOLS_DIR "${CMAKE_LINKER}" DIRECTORY)
68+
set_property(TEST DeviceIO_winapi PROPERTY
69+
ENVIRONMENT_MODIFICATION "PATH=path_list_append:${MSVC_BUILD_TOOLS_DIR}")
70+
endif()
71+
endif()
72+
73+
# --- macOS: IOKit backend via IOHIDUserDevice ------------------------------
74+
# Self-skips where the virtual-device entitlement / user consent isn't
75+
# available (e.g. hosted CI runners); usable locally / on a self-hosted Mac.
76+
if(APPLE AND TARGET hidapi_darwin)
77+
hidapi_add_vdev_test(DeviceIO_darwin test_virtual_device_mac.c hidapi_darwin)
78+
target_link_libraries(DeviceIO_darwin PRIVATE
79+
"-framework IOKit" "-framework CoreFoundation")
80+
endif()

src/tests/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# HIDAPI virtual-device tests
2+
3+
Backend-generic HIDAPI tests that run against a **virtual HID device** so they
4+
need no physical hardware. Every test is written purely against the public
5+
HIDAPI API plus the small backend-agnostic `test_virtual_device` interface
6+
(`test_virtual_device.h`), so the *same* test runs against every backend that
7+
provides a virtual-device implementation.
8+
9+
## Scenario protocol
10+
11+
Rather than injecting arbitrary input from the test (which would need
12+
platform-specific plumbing), the virtual device has a few **pre-recorded
13+
scenarios** baked in. The test triggers one using the ordinary public API — it
14+
sends a *Feature report* whose first payload byte is a `TEST_VDEV_CMD_*` command
15+
— and the device replays the matching canned *input report*. This keeps the test
16+
code 100% platform-neutral; all device behaviour lives in the per-backend
17+
provider. See `test_virtual_device.h` for the shared contract (report size,
18+
command bytes, expected payloads).
19+
20+
## Tests
21+
22+
| Test | What it exercises |
23+
|------|-------------------|
24+
| `test_device_io.c` | open → write an output report → trigger+read input reports (Feature write / interrupt read round-trip) → close |
25+
26+
## Providers
27+
28+
| Platform / backend | Provider | Mechanism | CI |
29+
|--------------------|----------|-----------|----|
30+
| Linux / hidraw | `test_virtual_device_uhid.c` | kernel `/dev/uhid` | runs in `builds.yml` (ubuntu-cmake) |
31+
| Linux / libusb | `test_virtual_device_rawgadget.c` | `/dev/raw-gadget` + `dummy_hcd` | builds in `builds.yml`; runs in the manual `libusb-vhid-test` job |
32+
| Windows / winapi | `test_virtual_device_win.c` + `windows/driver/` | modified vhidmini2 UMDF2 driver | builds in `builds.yml`; runs in the manual `win-vhid-test` job |
33+
| macOS / darwin | `test_virtual_device_mac.c` | `IOHIDUserDevice` (IOKit) | builds + runs in `builds.yml` (macos-cmake) |
34+
35+
Whenever a virtual device cannot be created or does not enumerate, the test
36+
returns CTest's **skip** code (77) instead of failing, so ordinary builds on any
37+
host stay green.
38+
39+
### Why some providers only run in a dedicated job
40+
41+
Some virtual devices need privileged, out-of-band setup that isn't appropriate
42+
for the per-push CI matrix:
43+
44+
* **Windows** — the vhidmini2 driver must be built, self-signed and installed
45+
(via `devcon`, no reboot) before the test, and removed afterwards. The
46+
`win-vhid-test` workflow does this end-to-end on a hosted `windows-latest`
47+
runner.
48+
* **Linux / libusb** — needs the `raw_gadget` and `dummy_hcd` kernel modules.
49+
`raw_gadget` ships in `linux-modules-extra`, but `dummy_hcd` is not packaged by
50+
Ubuntu and is built out-of-tree against the runner kernel headers. The
51+
`libusb-vhid-test` workflow handles this (best-effort; self-skips otherwise).
52+
* **macOS** — creating an `IOHIDUserDevice` is gated by the
53+
`com.apple.developer.hid.virtual.device` entitlement *and* an interactive
54+
Accessibility (TCC) consent prompt, neither available on a hosted runner, so
55+
the provider self-skips there. It is meant to actually run on a developer
56+
machine or a self-hosted runner where consent has been granted.
57+
58+
## Running locally
59+
60+
```sh
61+
# Linux (hidraw via uhid)
62+
cmake -B build -S . -DHIDAPI_WITH_TESTS=ON
63+
cmake --build build
64+
sudo modprobe uhid
65+
sudo ctest --test-dir build -R DeviceIO_hidraw --output-on-failure
66+
```
67+
68+
On Windows/macOS configure with `-DHIDAPI_WITH_TESTS=ON` and run `ctest`; the
69+
device-backed tests self-skip unless the corresponding virtual device has been
70+
set up (see the dedicated workflows under `.github/workflows/`).

0 commit comments

Comments
 (0)