diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index abe1a82..479fa35 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -9,10 +9,11 @@ # # This is a Windows-only library on a Linux runner, so what the jobs here can # prove is bounded and worth stating: the `test` job runs the -# operating-system-independent half, and `check` type-checks the COM half by -# cross-compiling it for two Windows architectures without linking. Running any -# of it needs the GitHub mirror's `windows-latest` runner, and hearing any of it -# needs a person -- see the README. +# operating-system-independent half natively, `check` type-checks the COM half +# by cross-compiling it for two Windows architectures without linking, and +# `wine` runs the whole Windows suite under Wine. Wine is not Windows, so the +# GitHub mirror's `windows-latest` runner is still the word on what Windows +# itself does, and hearing any of it needs a person -- see the README. name: test @@ -97,6 +98,26 @@ jobs: - name: Compile for every supported target run: nix develop -c zig build check --summary all + wine: + # The Windows test suite, run under Wine. Unlike the GitHub runner, this one + # has an audio endpoint -- a PulseAudio null sink that `tools/wine-test.sh` + # starts for the run -- so the loopback test plays a tone, records it back + # and checks its pitch rather than skipping. That makes it the only job that + # exercises playback and capture end to end. + # + # One job per optimize mode, for the same reasons as `test`. + needs: reuse + runs-on: nixos-do-s + strategy: + matrix: + optimize: [Debug, ReleaseSafe, ReleaseFast] + steps: + - name: Check out + uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Test under Wine + run: nix develop -c tools/wine-test.sh -Doptimize=${{ matrix.optimize }} + docs: # Publishes the generated API reference to # https://jeff.jcollie.page/zig-wsapi/ . @@ -105,7 +126,7 @@ jobs: # jobs within one workflow; a separate file could not wait on them. The tests # have to be green first, so a build that no longer compiles never replaces # the published documentation. - needs: [check, test] + needs: [check, test, wine] # ...and only from the default branch, or any pushed branch would publish # over the live site. if: ${{ forge.ref == 'refs/heads/main' }} diff --git a/README.md b/README.md index 1805206..48cc3c8 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ zig build check # compile for every supported target, without linking zig build docs # API reference into zig-out/docs zig build docs-serve # ...and serve it at http://127.0.0.1:8000/ zig build fuzz # the fuzz targets, once each +tools/wine-test.sh # the Windows suite under Wine, on x86-64 Linux zig build run-devices # list endpoints, volumes and sessions zig build run-tone -- 440 2 @@ -301,10 +302,19 @@ On **Windows** the suite adds the COM tests, the apartment handling, and and asserts with a Goertzel filter that the energy came back at the right pitch. That one test exercises the whole library at once. -A hosted continuous-integration runner generally has **no audio endpoint**, so the -playback and capture tests report `SkipZigTest` there. What the Windows job actually buys -is that every symbol exists, the structure layouts are right, and the apartment handling -is correct. The audible half needs a real machine, which means before a release: +Under **Wine**, on x86-64 Linux, `tools/wine-test.sh` runs that same Windows suite. It +gives Wine a private PulseAudio server whose only sink is a null sink, so nothing reaches +the speakers and the loopback test still has an endpoint to play to and record back from. +Wine's audio path is not Windows', but it is the one place the loopback test runs on every +push, since the Forgejo workflow runs it there in every optimize mode. The script uses a +throwaway Wine prefix and leaves `~/.wine` alone. The dev shell carries the 64-bit build +of Wine it needs; the plain `wine` package is 32-bit only and rejects the test binaries +with "Bad EXE format". + +A hosted Windows runner generally has **no audio endpoint**, so the playback and capture +tests report `SkipZigTest` there. What the GitHub Windows job actually buys is that every +symbol exists in real Windows DLLs, the structure layouts are right, and the apartment +handling is correct. The audible half needs a real machine, which means before a release: - that a tone is clean and not clicking, and that a chord sounds the same through the pull API; diff --git a/flake.nix b/flake.nix index 912ad29..455f965 100644 --- a/flake.nix +++ b/flake.nix @@ -41,10 +41,9 @@ # No `packages` and no `checks`. # # There is nothing for Nix to build: the library is consumed as source - # through Zig's package manager, and its test binaries only do anything on - # Windows -- which Nix cannot produce a runnable result for. `zig build - # check` in CI is what stands in for a build here, and it needs only the dev - # shell. + # through Zig's package manager, and its test binaries are Windows + # programs. `zig build check` in CI stands in for a build, and Wine in the + # dev shell is what runs the tests; both need only the shell. devShells = forAllShells ( system: @@ -61,6 +60,17 @@ pkgs.reuse pkgs.typos pkgs.zig_0_16 + ] + # Wine, to run the Windows test binaries on Linux, and PulseAudio + # for `tools/wine-test.sh` to give it a null sink to play to. + # + # The WoW64 build, not plain `wine`: that one is 32-bit only and + # refuses an x86-64 program with "Bad EXE format". x86-64 only, + # because the tests are built for x86-64 Windows and a Wine on + # any other architecture runs that architecture's Windows programs. + ++ lib.optionals (system == "x86_64-linux") [ + pkgs.pulseaudio + pkgs.wineWow64Packages.stable ]; }; } diff --git a/tools/wine-test.sh b/tools/wine-test.sh new file mode 100755 index 0000000..4677edc --- /dev/null +++ b/tools/wine-test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie +# SPDX-License-Identifier: MIT + +# Run the Windows test suite under Wine, against an audio server of its own. +# +# tools/wine-test.sh # Debug +# tools/wine-test.sh -Doptimize=ReleaseFast +# +# Any arguments are passed to `zig build test`. Run it from the dev shell, +# which carries the 64-bit Wine and PulseAudio this needs. +# +# Wine plays audio through PulseAudio, so what the loopback test hears is what a +# PulseAudio server gives it. This starts a private one whose only sink is a +# null sink: nothing reaches the speakers, the result does not depend on +# whatever the machine's own audio happens to be doing, and a CI runner with no +# sound hardware gets a real endpoint to play to and record back from. Without +# any server at all Wine still offers an endpoint, but one whose +# `IAudioClient::Initialize` answers `E_NOTIMPL`, and the loopback test fails +# on it. +# +# The Wine prefix is private too, and thrown away afterwards, so a run neither +# uses nor disturbs `~/.wine`. + +set -euo pipefail + +scratch=$(mktemp -d) +cleanup() { + pulseaudio --kill 2>/dev/null || true + wineserver --kill 2>/dev/null || true + rm -rf "$scratch" +} +trap cleanup EXIT + +mkdir -m 700 "$scratch/runtime" +export XDG_RUNTIME_DIR="$scratch/runtime" +export WINEPREFIX="$scratch/wine" +export WINEDEBUG="${WINEDEBUG:--all}" +# So that PulseAudio and Wine find the private server rather than a session's. +unset PULSE_SERVER DBUS_SESSION_BUS_ADDRESS + +# `-n` skips the system default.pa, which would try to load hardware modules. +pulseaudio \ + --daemonize=yes \ + --exit-idle-time=-1 \ + -n \ + --load=module-native-protocol-unix \ + --load="module-null-sink sink_name=wine_test" \ + --load=module-always-sink + +zig build test -Dtarget=x86_64-windows-gnu -fwine --summary all "$@"