diff --git a/.github/workflows/native-switch2kit.yml b/.github/workflows/native-switch2kit.yml index 469a5cb4bb61..f09e3a8a1d47 100644 --- a/.github/workflows/native-switch2kit.yml +++ b/.github/workflows/native-switch2kit.yml @@ -1,7 +1,14 @@ +# Upstream CMake/CTest plus scoped Switch2Kit qualification. See AGENTS.md. name: Native Switch2Kit on: pull_request: workflow_dispatch: + inputs: + target: + description: 'Tests only, or explicit native qualification/cache priming' + type: choice + default: tests + options: [tests, sdk, macos, linux, windows, all] permissions: contents: read concurrency: @@ -9,25 +16,41 @@ concurrency: cancel-in-progress: true jobs: wiring: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 3 + outputs: + sdk: ${{ steps.scope.outputs.sdk }} + macos: ${{ steps.scope.outputs.macos }} + linux: ${{ steps.scope.outputs.linux }} + windows: ${{ steps.scope.outputs.windows }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - - name: Check exact-width SDL adapter capacity + fetch-depth: 1 + - name: Select validation from the actual changed inputs + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + TARGET: ${{ inputs.target || 'tests' }} + run: | + if [ -n "$BASE_SHA" ]; then + git fetch --no-tags --depth 1 origin "$BASE_SHA" + python3 Tools/switch2kit_ci.py --base "$BASE_SHA" + else + python3 Tools/switch2kit_ci.py --target "$TARGET" + fi + - name: Fetch only the two dependencies used by the focused tests + run: git submodule update --init --depth 1 Externals/Switch2Kit Externals/SDL/SDL + - name: Run the additions through CTest without building the emulator run: | - git submodule update --init Externals/Switch2Kit Externals/SDL/SDL - CXX=clang++ python3 Tools/test_switch2kit_capacity.py - CXX=g++ python3 Tools/test_switch2kit_capacity.py - - run: python3 Tools/test_switch2kit.py - - run: python3 Tools/test_switch2kit_autoconnect.py - - run: python3 Tools/test_switch2kit_mapping.py --sanitize - - name: Execute host lifecycle and identity regressions with sanitizers - run: python3 Tools/test_switch2kit_host.py --sanitize + cmake -S Source/UnitTests/Switch2Kit -B build-switch2kit-tests + ctest --test-dir build-switch2kit-tests --output-on-failure --no-tests=error sdk-regressions: - if: github.repository == 'jmonster/dolphin' + needs: wiring + if: needs.wiring.outputs.sdk == 'true' runs-on: macos-15 - timeout-minutes: 25 + timeout-minutes: 5 env: DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer steps: @@ -35,7 +58,12 @@ jobs: with: persist-credentials: false - name: Initialize exactly the pinned SDK and Dolphin SDL - run: git submodule update --init Externals/Switch2Kit Externals/SDL/SDL + run: git submodule update --init --depth 1 Externals/Switch2Kit Externals/SDL/SDL + - name: Reuse the pinned Swift test build, not a previous test result + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: Externals/Switch2Kit/.build + key: s2k-sdk-tests-${{ runner.os }}-${{ runner.arch }}-xcode26.3-${{ hashFiles('Externals/Switch2Kit/Package.swift', 'Externals/Switch2Kit/Package.resolved', 'Externals/Switch2Kit/Sources/**', 'Externals/Switch2Kit/Tests/**') }} - name: Exercise the real SDK and in-process SDL adapter without hardware run: | set -o pipefail @@ -45,90 +73,51 @@ jobs: bash Externals/Switch2Kit/tests/sdl-inprocess/verify.sh \ 2>&1 | tee switch2kit-sdl-tests.log - name: SDK test diagnostics - if: always() + if: failure() || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: Switch2Kit-sdk-regressions path: switch2kit-*-tests.log macos: - if: github.repository == 'jmonster/dolphin' - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - arch: arm64 - - os: macos-15-intel - arch: x86_64 - runs-on: ${{ matrix.os }} - timeout-minutes: 90 - env: - DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + needs: [wiring, sdk-regressions] + if: >- + !cancelled() && needs.wiring.result == 'success' && + (needs.sdk-regressions.result == 'success' || needs.sdk-regressions.result == 'skipped') && + needs.wiring.outputs.macos == 'true' + uses: ./.github/workflows/switch2kit-macos.yml + linux: + needs: [wiring, sdk-regressions] + if: >- + !cancelled() && needs.wiring.result == 'success' && + (needs.sdk-regressions.result == 'success' || needs.sdk-regressions.result == 'skipped') && + needs.wiring.outputs.linux == 'true' + uses: ./.github/workflows/switch2kit-linux.yml + windows: + needs: [wiring, sdk-regressions] + if: >- + !cancelled() && needs.wiring.result == 'success' && + (needs.sdk-regressions.result == 'success' || needs.sdk-regressions.result == 'skipped') && + needs.wiring.outputs.windows == 'true' + uses: ./.github/workflows/switch2kit-windows.yml + checks: + name: Switch2Kit checks + needs: [wiring, sdk-regressions, macos, linux, windows] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 1 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - submodules: recursive - persist-credentials: false - - name: Install build dependencies - run: brew install cmake ninja nasm automake libtool qt@6 - - name: Check source pin and integration - run: | - test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = "$(git ls-tree HEAD Externals/Switch2Kit | awk '{print $3}')" - python3 Tools/test_switch2kit.py - python3 Tools/test_switch2kit_autoconnect.py - python3 Tools/test_switch2kit_host.py --sanitize - python3 Tools/test_switch2kit_mapping.py --sanitize - - name: Build the complete application + - name: Require every selected check, including native qualification env: - ARCH: ${{ matrix.arch }} + RESULTS: ${{ toJSON(needs) }} run: | - set -o pipefail - test "$(uname -m)" = "$ARCH" - cmake -S . -B build-switch2kit -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ - -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ - -DENABLE_SWITCH2KIT=ON -DENABLE_QT=ON -DENABLE_SDL=ON \ - -DUSE_SYSTEM_SDL3=OFF \ - -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)" \ - -DENABLE_VULKAN=OFF -DENABLE_TESTS=OFF -DPOSTPROCESS_BUNDLE=ON \ - 2>&1 | tee switch2kit-configure.log - cmake --build build-switch2kit --target dolphin-emu --parallel 3 \ - 2>&1 | tee switch2kit-build.log - - name: Inspect and archive application - env: - ARCH: ${{ matrix.arch }} - run: | - app=build-switch2kit/Binaries/DolphinQt.app - test -d "$app" - test -f "$app/Contents/Frameworks/libSwitch2KitC.dylib" - /usr/libexec/PlistBuddy -c 'Print :NSBluetoothAlwaysUsageDescription' "$app/Contents/Info.plist" - lipo "$app/Contents/MacOS/DolphinQt" -verify_arch "$ARCH" - lipo "$app/Contents/Frameworks/libSwitch2KitC.dylib" -verify_arch "$ARCH" - test -f "$app/Contents/Resources/Sys/Profiles/GCPad/Switch2Kit GameCube.ini" - test -f "$app/Contents/Resources/Sys/Profiles/GCPad/Switch2Kit Pro Controller 2.ini" - ditto -c -k --keepParent "$app" "Dolphin-Switch2Kit-$ARCH.zip" - - name: Launch, normally quit and relaunch the exact ZIP with build roots denied - env: - ARCH: ${{ matrix.arch }} - run: | - python3 Tools/test_switch2kit_bundle.py "Dolphin-Switch2Kit-$ARCH.zip" \ - --architecture "$ARCH" --report switch2kit-validation.json - - name: Application artifact (not hardware-qualified or notarized) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: Dolphin-Switch2Kit-${{ matrix.arch }} - path: | - Dolphin-Switch2Kit-${{ matrix.arch }}.zip - switch2kit-validation.json - if-no-files-found: error - - name: Build diagnostics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: Switch2Kit-${{ matrix.arch }}-diagnostics - path: | - switch2kit-*.log - switch2kit-*.sample - switch2kit-validation.json - build-switch2kit/CMakeCache.txt - build-switch2kit/CMakeFiles/CMakeConfigureLog.yaml + python3 - <<'PYTHON' + import json, os + jobs = json.loads(os.environ['RESULTS']) + assert jobs['wiring']['result'] == 'success', 'Focused tests or change selection failed' + selected = jobs['wiring']['outputs'] + for target, job in [('sdk', 'sdk-regressions'), ('macos', 'macos'), + ('linux', 'linux'), ('windows', 'windows')]: + assert selected.get(target) in ('true', 'false'), f'Missing selection for {target}' + expected = 'success' if selected[target] == 'true' else 'skipped' + assert jobs[job]['result'] == expected, f'{job}: {jobs[job]["result"]}, expected {expected}' + PYTHON diff --git a/.github/workflows/switch2kit-desktop.yml b/.github/workflows/switch2kit-desktop.yml index 1c3026902ad1..e791611e0861 100644 --- a/.github/workflows/switch2kit-desktop.yml +++ b/.github/workflows/switch2kit-desktop.yml @@ -1,6 +1,6 @@ +# Manual qualification/artifacts only. Never add automatic triggers; see AGENTS.md. name: Switch2Kit desktop platforms on: - pull_request: workflow_dispatch: permissions: contents: read @@ -10,7 +10,7 @@ concurrency: jobs: source: runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 3 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: diff --git a/.github/workflows/switch2kit-linux.yml b/.github/workflows/switch2kit-linux.yml index e9fe1b9dc303..ee3ffda1407a 100644 --- a/.github/workflows/switch2kit-linux.yml +++ b/.github/workflows/switch2kit-linux.yml @@ -1,6 +1,7 @@ +# Called automatically for affected inputs; never a duplicate PR trigger. name: Switch2Kit Linux application on: - pull_request: + workflow_call: workflow_dispatch: permissions: contents: read @@ -11,24 +12,61 @@ jobs: linux: runs-on: ubuntu-24.04 container: swift:6.2.1-noble - timeout-minutes: 90 + timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 45 || 10 }} env: DEBIAN_FRONTEND: noninteractive + # One cache archive per job, not network requests for every object. + SCCACHE_DIR: /github/home/.cache/sccache + SCCACHE_CACHE_SIZE: 2G + NINJA_STATUS: '[%f/%t %r running, %e sec] ' + # Configure probes and standalone sanitizer/SDK tests must compile directly. + S2K_CI_COMPILER_CACHE: sccache + S2K_CI_LINUX_LINKER: /usr/bin/ld.lld-19 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - submodules: recursive + submodules: false - name: Install native dependencies run: | apt-get update - apt-get install -y --no-install-recommends build-essential cmake ninja-build python3 pkg-config qt6-base-dev qt6-base-private-dev qt6-svg-dev libbluetooth-dev libevdev-dev libudev-dev libusb-1.0-0-dev libasound2-dev libpulse-dev libx11-dev libxi-dev libxrandr-dev libegl1-mesa-dev libgl1-mesa-dev libsystemd0 dbus xvfb xauth openbox wmctrl x11-utils + apt-get install -y --no-install-recommends build-essential clang clang-19 lld-19 cmake ninja-build python3 pkg-config qt6-base-dev qt6-base-private-dev qt6-svg-dev libbluetooth-dev libevdev-dev libudev-dev libusb-1.0-0-dev libasound2-dev libpulse-dev libx11-dev libxi-dev libxrandr-dev libegl1-mesa-dev libgl1-mesa-dev libsystemd0 dbus xvfb xauth openbox wmctrl x11-utils git config --global --add safe.directory "$PWD" + - name: Fetch and verify native gitlinks in parallel + run: python3 Tools/checkout_native.py + - name: Verify upstream C++23 library support before compiling Dolphin + run: | + # Clang 18 cannot use libstdc++'s std::expected. Do not spoof feature macros. + printf '%s\n' '#include ' \ + 'static_assert(std::expected(42).value() == 42);' | \ + /usr/bin/clang++-19 -std=c++23 -x c++ -fsyntax-only - + - name: Restore compiler objects (never test results or applications) + id: compiler-objects + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: s2k-objects-v4-linux-clang19-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + s2k-objects-v4-linux-clang19-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}- + s2k-objects-v4-linux-clang19-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}- + - name: Cache native compiler results (tests still run) + id: compiler-cache + uses: Mozilla-Actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad + with: + version: v0.10.0 - name: Build and install the controller-enabled application shell: bash run: | set -o pipefail - bash Tools/build-switch2kit-linux.sh 2>&1 | tee linux-build.log + jobs="$(nproc)" + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ]; then jobs=$((jobs + 2)); fi + S2K_BUILD_JOBS="$jobs" bash Tools/build-switch2kit-linux.sh \ + -DCMAKE_C_COMPILER=/usr/bin/clang-19 -DCMAKE_CXX_COMPILER=/usr/bin/clang++-19 \ + "-DCMAKE_PROJECT_dolphin-emu_INCLUDE=$PWD/Tools/ci-native.cmake" \ + 2>&1 | tee linux-build.log + - name: Compiler cache statistics + if: always() + run: sccache --show-stats - name: Execute controller policies run: | python3 Tools/test_switch2kit_mapping.py --sanitize @@ -53,6 +91,7 @@ jobs: --forbidden-root "$PWD" --forbidden-root "$RUNNER_TEMP/disabled-build-tree" \ --forbidden-root "$RUNNER_TEMP/unavailable-staged-application" - name: Controller-enabled development build + if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: Dolphin-Switch2Kit-linux-x86_64 @@ -60,7 +99,7 @@ jobs: retention-days: 14 if-no-files-found: error - name: Native diagnostics - if: always() + if: failure() || cancelled() || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: Dolphin-Switch2Kit-linux-diagnostics @@ -68,39 +107,101 @@ jobs: linux-*.log linux-launch.json retention-days: 7 + # Keep valid compiler objects even when a later build/test is cancelled. + # Never save build trees, applications, configuration answers or test results. + - name: Save compiler objects after native validation + if: always() && steps.compiler-cache.outcome == 'success' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: ${{ steps.compiler-objects.outputs.cache-primary-key }} disabled: + needs: linux # A complete ordinary application build in an image without Swift. This is # not a controller-enabled artifact and does not use the SDK build helper. runs-on: ubuntu-24.04 - container: ubuntu:24.04 - timeout-minutes: 90 + container: + image: ubuntu:24.04 + # Upstream memory-mapping tests exceed Docker's default 64 MiB /dev/shm. + options: --shm-size=512m + timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 45 || 10 }} env: DEBIAN_FRONTEND: noninteractive + # One cache archive per job, not network requests for every object. + SCCACHE_DIR: /github/home/.cache/sccache + SCCACHE_CACHE_SIZE: 2G + NINJA_STATUS: '[%f/%t %r running, %e sec] ' + # Configure probes and standalone sanitizer/SDK tests must compile directly. + S2K_CI_COMPILER_CACHE: sccache + S2K_CI_LINUX_LINKER: /usr/bin/ld.lld-19 steps: + - name: Check shared memory before installing dependencies or compiling + shell: bash + run: | + set -euo pipefail + df -h /dev/shm + available=$(df -B1 --output=avail /dev/shm | tail -n 1) + if ! [ "$available" -ge "$((128 * 1024 * 1024))" ]; then + echo '::error::Upstream memory tests need at least 128 MiB free in /dev/shm. Keep --shm-size=512m on this container.' >&2 + exit 1 + fi - name: Install ordinary native dependencies without Swift run: | apt-get update - apt-get install -y --no-install-recommends git ca-certificates build-essential cmake ninja-build python3 pkg-config qt6-base-dev qt6-base-private-dev qt6-svg-dev libbluetooth-dev libevdev-dev libudev-dev libusb-1.0-0-dev libasound2-dev libpulse-dev libx11-dev libxi-dev libxrandr-dev libegl1-mesa-dev libgl1-mesa-dev libsystemd0 dbus + apt-get install -y --no-install-recommends git ca-certificates build-essential clang clang-19 lld-19 cmake ninja-build python3 pkg-config qt6-base-dev qt6-base-private-dev qt6-svg-dev libbluetooth-dev libevdev-dev libudev-dev libusb-1.0-0-dev libasound2-dev libpulse-dev libx11-dev libxi-dev libxrandr-dev libegl1-mesa-dev libgl1-mesa-dev libsystemd0 dbus if command -v swift || command -v swiftc; then echo 'The backend-disabled qualification image must not contain Swift.' >&2 exit 1 fi + - name: Verify upstream C++23 library support before compiling Dolphin + run: | + # Use the same real compiler/library pair as the enabled build. + printf '%s\n' '#include ' \ + 'static_assert(std::expected(42).value() == 42);' | \ + /usr/bin/clang++-19 -std=c++23 -x c++ -fsyntax-only - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - submodules: recursive + submodules: false + - name: Fetch and verify native gitlinks in parallel + run: | + git config --global --add safe.directory "$PWD" + python3 Tools/checkout_native.py + - name: Restore compiler objects (never test results or applications) + id: compiler-objects + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: s2k-objects-v4-linux-clang19-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + s2k-objects-v4-linux-clang19-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}- + s2k-objects-v4-linux-clang19-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}- + - name: Cache native compiler results (tests still run) + id: compiler-cache + uses: Mozilla-Actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad + with: + version: v0.10.0 - name: Build the complete application with Switch2Kit disabled shell: bash run: | set -euo pipefail git config --global --add safe.directory "$PWD" - cmake -S . -B build-disabled -G Ninja -DCMAKE_BUILD_TYPE=Release \ + cmake -S . -B build-switch2kit -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=/usr/bin/clang-19 -DCMAKE_CXX_COMPILER=/usr/bin/clang++-19 \ + "-DCMAKE_PROJECT_dolphin-emu_INCLUDE=$PWD/Tools/ci-native.cmake" \ + -DCMAKE_INSTALL_PREFIX="$PWD/build-switch2kit/install" \ -DENABLE_SWITCH2KIT=OFF -DENABLE_SDL=ON -DENABLE_QT=ON \ - -DUSE_SYSTEM_SDL3=OFF -DENABLE_TESTS=OFF -DENABLE_VULKAN=OFF \ + -DUSE_SYSTEM_SDL3=OFF -DENABLE_TESTS=ON -DENABLE_VULKAN=OFF \ -DENABLE_AUTOUPDATE=OFF 2>&1 | tee disabled-configure.log - cmake --build build-disabled --target dolphin-emu --parallel 3 2>&1 | tee disabled-build.log - ldd build-disabled/Binaries/dolphin-emu | tee disabled-dependencies.log + # Submit the expensive emitter case and moc first, in the same full + # application/test graph. They must not become a serial compile tail. + jobs="$(nproc)" + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ]; then jobs=$((jobs + 2)); fi + cmake --build build-switch2kit --target x64EmitterTest dolphin-emu_autogen tests dolphin-emu --parallel "$jobs" 2>&1 | tee disabled-build.log + # Building tests does not execute them; retain the complete CTest target. + cmake --build build-switch2kit --target unittests --parallel "$(nproc)" 2>&1 | tee upstream-unittests.log + ldd build-switch2kit/Binaries/dolphin-emu | tee disabled-dependencies.log ! grep -q 'not found' disabled-dependencies.log ! grep -Ei 'Switch2Kit|libswift|libFoundation|libdispatch|libBlocksRuntime' disabled-dependencies.log # Qt initializes for --version too. Keep this disabled-backend smoke @@ -108,11 +209,24 @@ jobs: profile=$(mktemp -d) trap 'rm -rf "$profile"' EXIT HOME="$profile" XDG_CONFIG_HOME="$profile/config" XDG_CACHE_HOME="$profile/cache" \ - QT_QPA_PLATFORM=offscreen build-disabled/Binaries/dolphin-emu --user "$profile/user" --version - - name: Backend-disabled build diagnostics + QT_QPA_PLATFORM=offscreen build-switch2kit/Binaries/dolphin-emu --user "$profile/user" --version + - name: Compiler cache statistics if: always() + run: sccache --show-stats + - name: Backend-disabled build diagnostics + if: failure() || cancelled() || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: Dolphin-without-Switch2Kit-linux-diagnostics - path: disabled-*.log + path: | + disabled-*.log + upstream-unittests.log retention-days: 7 + # Keep valid compiler objects even when a later build/test is cancelled. + # Never save build trees, applications, configuration answers or test results. + - name: Save compiler objects after native validation + if: always() && steps.compiler-cache.outcome == 'success' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: ${{ steps.compiler-objects.outputs.cache-primary-key }} diff --git a/.github/workflows/switch2kit-macos.yml b/.github/workflows/switch2kit-macos.yml new file mode 100644 index 000000000000..8d530fcdd5ce --- /dev/null +++ b/.github/workflows/switch2kit-macos.yml @@ -0,0 +1,187 @@ +# Called automatically for affected inputs; never a duplicate PR trigger. +name: Switch2Kit macOS qualification +on: + workflow_call: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: switch2kit-macos-${{ github.ref }} + cancel-in-progress: true +jobs: + macos: + strategy: + fail-fast: true + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + runs-on: ${{ matrix.os }} + timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 60 || 10 }} + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + # Do not upgrade unrelated installed Qt modules after installing qtbase. + # Required components and the complete relocated application are validated below. + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + # One cache archive per job, not network requests for every object. + SCCACHE_DIR: ${{ github.workspace }}/.ci-cache/sccache + SCCACHE_CACHE_SIZE: 2G + NINJA_STATUS: '[%f/%t %r running, %e sec] ' + # Configure probes and standalone sanitizer/SDK tests must compile directly. + S2K_CI_COMPILER_CACHE: sccache + steps: + - name: Restore Intel runner CPU availability + if: matrix.arch == 'x86_64' + run: | + set -euo pipefail + # The retained runner profile showed PerfPowerServices using a core. + # Use the runner-images workaround, scoped to this disposable Intel VM. + # https://github.com/actions/runner-images/issues/13358 + ps -axo pid,pcpu,comm | sort -rn -k2 | sed -n '1,15p' + sudo defaults -currentHost write /Library/Preferences/com.apple.powerlogd SMCMonitorCadence 0 + if pgrep -x PerfPowerServices >/dev/null; then + sudo killall PerfPowerServices + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + submodules: false + persist-credentials: false + - name: Fetch and verify native gitlinks in parallel + run: python3 Tools/checkout_native.py + - name: Restore compiler objects (never test results or applications) + id: compiler-objects + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: s2k-objects-v4-${{ matrix.os }}-${{ matrix.arch }}-xcode26.3-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + s2k-objects-v4-${{ matrix.os }}-${{ matrix.arch }}-xcode26.3-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}- + s2k-objects-v4-${{ matrix.os }}-${{ matrix.arch }}-xcode26.3-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}- + - name: Cache native compiler results (tests still run) + id: compiler-cache + uses: Mozilla-Actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad + with: + version: v0.10.0 + - name: Install build dependencies + run: | + set -euo pipefail + installed="$(brew list --formula --versions | awk '{print $1}')" + missing=() + for formula in cmake ninja nasm automake libtool qtbase qtsvg; do + grep -Fxq "$formula" <<< "$installed" || missing+=("$formula") + done + if [ "${#missing[@]}" -ne 0 ]; then + brew install "${missing[@]}" + fi + # Split Qt formulae share Homebrew's linked installation prefix. + # A qtbase keg is not a complete Qt prefix: Svg lives in qtsvg. + qt_prefix="$(brew --prefix)" + echo "S2K_QT_PREFIX=$qt_prefix" >> "$GITHUB_ENV" + # Resolve the selected Xcode compiler once, rather than invoking the + # /usr/bin toolchain selector for every probe and translation unit. + cc="$(xcrun --find clang)" + cxx="$(xcrun --find clang++)" + test -x "$cc" && test -x "$cxx" + # Direct Xcode compiler paths do not inherit xcrun's SDK selection. + # Standalone sanitizer tests need the same headers and libraries too. + sdk="$(xcrun --sdk macosx --show-sdk-path)" + test -d "$sdk" + printf 'CC=%s\nCXX=%s\nOBJC=%s\nOBJCXX=%s\nSDKROOT=%s\n' \ + "$cc" "$cxx" "$cc" "$cxx" "$sdk" >> "$GITHUB_ENV" + printf '%s\n' '#include ' \ + 'int main() { std::atomic value{42}; return value.load() == 42 ? 0 : 1; }' | \ + SDKROOT="$sdk" "$cxx" -std=c++23 -x c++ - -o "$RUNNER_TEMP/s2k-toolchain-probe" + "$RUNNER_TEMP/s2k-toolchain-probe" + for component in Qt6 Qt6Svg; do + test -f "$qt_prefix/lib/cmake/$component/${component}Config.cmake" || { + echo "::error::Missing linked Qt package: $component in $qt_prefix" >&2 + exit 1 + } + done + - name: Check source pin and integration + run: | + test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = "$(git ls-tree HEAD Externals/Switch2Kit | awk '{print $3}')" + python3 Tools/test_switch2kit.py + python3 Tools/test_switch2kit_autoconnect.py + python3 Tools/test_switch2kit_host.py --sanitize + python3 Tools/test_switch2kit_mapping.py --sanitize + - name: Build the complete application + env: + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + test "$(uname -m)" = "$ARCH" + ps -axo pid,pcpu,comm | sort -rn -k2 | sed -n '1,15p' + cmake --profiling-format=google-trace \ + --profiling-output="$PWD/switch2kit-configure-profile.json" \ + -S . -B build-switch2kit -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + "-DCMAKE_PROJECT_dolphin-emu_INCLUDE=$PWD/Tools/ci-native.cmake" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" -DCMAKE_OSX_SYSROOT="$SDKROOT" \ + -DENABLE_SWITCH2KIT=ON -DENABLE_QT=ON -DENABLE_SDL=ON \ + -DUSE_SYSTEM_SDL3=OFF \ + -DCMAKE_PREFIX_PATH="$S2K_QT_PREFIX" \ + -DQt6_DIR="$S2K_QT_PREFIX/lib/cmake/Qt6" \ + -DENABLE_VULKAN=OFF -DENABLE_TESTS=OFF -DPOSTPROCESS_BUNDLE=ON \ + 2>&1 | tee switch2kit-configure.log + jobs="$(sysctl -n hw.logicalcpu)" + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ]; then jobs=$((jobs + 2)); fi + cmake --build build-switch2kit --target dolphin-emu_autogen dolphin-emu --parallel "$jobs" \ + 2>&1 | tee switch2kit-build.log + - name: Compiler cache statistics + if: always() + run: sccache --show-stats + - name: Inspect and archive application + env: + ARCH: ${{ matrix.arch }} + run: | + app=build-switch2kit/Binaries/DolphinQt.app + test -d "$app" + test -f "$app/Contents/Frameworks/libSwitch2KitC.dylib" + /usr/libexec/PlistBuddy -c 'Print :NSBluetoothAlwaysUsageDescription' "$app/Contents/Info.plist" + lipo "$app/Contents/MacOS/DolphinQt" -verify_arch "$ARCH" + lipo "$app/Contents/Frameworks/libSwitch2KitC.dylib" -verify_arch "$ARCH" + test -f "$app/Contents/Resources/Sys/Profiles/GCPad/Switch2Kit GameCube.ini" + test -f "$app/Contents/Resources/Sys/Profiles/GCPad/Switch2Kit Pro Controller 2.ini" + ditto -c -k --keepParent "$app" "Dolphin-Switch2Kit-$ARCH.zip" + - name: Launch, normally quit and relaunch the exact ZIP with build roots denied + env: + ARCH: ${{ matrix.arch }} + run: | + python3 Tools/test_switch2kit_bundle.py "Dolphin-Switch2Kit-$ARCH.zip" \ + --architecture "$ARCH" --report switch2kit-validation.json + - name: Application artifact (not hardware-qualified or notarized) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: Dolphin-Switch2Kit-${{ matrix.arch }} + path: | + Dolphin-Switch2Kit-${{ matrix.arch }}.zip + switch2kit-validation.json + if-no-files-found: error + - name: Build diagnostics + if: failure() || cancelled() || github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: Switch2Kit-${{ matrix.arch }}-diagnostics + path: | + switch2kit-*.log + switch2kit-*.sample + switch2kit-validation.json + switch2kit-configure-profile.json + build-switch2kit/build.ninja + build-switch2kit/compile_commands.json + build-switch2kit/CMakeCache.txt + build-switch2kit/CMakeFiles/CMakeConfigureLog.yaml + # Keep valid compiler objects even when a later build/test is cancelled. + # Never save build trees, applications, configuration answers or test results. + - name: Save compiler objects after native validation + if: always() && steps.compiler-cache.outcome == 'success' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: ${{ steps.compiler-objects.outputs.cache-primary-key }} diff --git a/.github/workflows/switch2kit-windows.yml b/.github/workflows/switch2kit-windows.yml index 367ec7eb9ceb..7ffd124333db 100644 --- a/.github/workflows/switch2kit-windows.yml +++ b/.github/workflows/switch2kit-windows.yml @@ -1,6 +1,7 @@ +# Called automatically for affected inputs; never a duplicate PR trigger. name: Switch2Kit Windows application on: - pull_request: + workflow_call: workflow_dispatch: permissions: contents: read @@ -10,24 +11,53 @@ concurrency: jobs: windows: runs-on: windows-2025-vs2026 - timeout-minutes: 120 + timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 45 || 10 }} env: VCPKG_MAX_CONCURRENCY: 3 + # One cache archive per job, not network requests for every object. + SCCACHE_DIR: ${{ github.workspace }}/.ci-cache/sccache + SCCACHE_CACHE_SIZE: 2G + NINJA_STATUS: '[%f/%t %r running, %e sec] ' + # Configure probes and standalone sanitizer/SDK tests must compile directly. + S2K_CI_COMPILER_CACHE: sccache steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - submodules: recursive + submodules: false + - name: Fetch and verify native gitlinks in parallel + run: python Tools/checkout_native.py + - name: Restore compiler objects (never test results or applications) + id: compiler-objects + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: s2k-objects-v4-windows-vs2026-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + s2k-objects-v4-windows-vs2026-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}-${{ github.sha }}- + s2k-objects-v4-windows-vs2026-${{ github.event_name == 'workflow_dispatch' && 'release' || 'smoke' }}- + - name: Cache native compiler results (tests still run) + id: compiler-cache + uses: Mozilla-Actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad + with: + version: v0.10.0 - uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 with: # Swift 6.2 ships Clang 19, which the VS 2026 STL rejects. swift-version: swift-6.3.3-release swift-build: 6.3.3-RELEASE + cache: true - name: Build the complete controller-enabled application shell: pwsh run: | - & ./Tools/build-switch2kit-windows.ps1 2>&1 | Tee-Object windows-build.log + # Load after upstream's MSVC flag initialization, not as shadowed -D flags. + $cmakeArgs = @("-DCMAKE_PROJECT_dolphin-emu_INCLUDE=$PWD/Tools/ci-native.cmake") + & ./Tools/build-switch2kit-windows.ps1 -CMakeArgs $cmakeArgs 2>&1 | Tee-Object windows-build.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Compiler cache statistics + if: always() + shell: pwsh + run: sccache --show-stats - name: Stage and launch the application away from the build tree shell: pwsh run: | @@ -49,6 +79,7 @@ jobs: & ./Tools/test_switch2kit_windows_launch.ps1 -Archive 'Dolphin-Switch2Kit-windows-x86_64.zip' -ForbiddenRoot "$PWD" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Controller-enabled development application + if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: Dolphin-Switch2Kit-windows-x86_64 @@ -56,7 +87,7 @@ jobs: retention-days: 14 if-no-files-found: error - name: Native diagnostics - if: always() + if: failure() || cancelled() || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: Dolphin-Switch2Kit-windows-diagnostics @@ -64,3 +95,11 @@ jobs: windows-*.log windows-launch.json retention-days: 7 + # Keep valid compiler objects even when a later build/test is cancelled. + # Never save build trees, applications, configuration answers or test results. + - name: Save compiler objects after native validation + if: always() && steps.compiler-cache.outcome == 'success' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.SCCACHE_DIR }} + key: ${{ steps.compiler-objects.outputs.cache-primary-key }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..ee1e3c60daf7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# Working on this Dolphin fork + +## Upstream first + +Keep Dolphin's upstream build system, Google Test suite, coding conventions and +project documentation intact. Make the smallest additive change for Switch2Kit. +Do not introduce a fork-wide test framework, custom YAML policy language, or a +blanket rule forbidding native validation. Upstream's external Buildbot service +is not automatically inherited by this fork; do not claim otherwise. + +The existing `unittests` target remains the baseline. Register our focused tests +with CTest in `Source/UnitTests/Switch2Kit`, not a parallel Python test runner. +On supported POSIX hosts they belong to the ordinary `ENABLE_TESTS` suite even +with `ENABLE_SWITCH2KIT=OFF`. Never add a separate test opt-in or gate the fixture +regressions on the application backend; they do not need Swift or Bluetooth. +The same registration can be configured alone for fast iteration: + +```sh +git submodule update --init --depth 1 Externals/Switch2Kit Externals/SDL/SDL +cmake -S Source/UnitTests/Switch2Kit -B build-switch2kit-tests +ctest --test-dir build-switch2kit-tests --output-on-failure --no-tests=error +``` + +## Efficiency without loss of validation + +Fast tests must exercise the smallest relevant production boundary. Keep ASan / +UBSan, both Linux compiler-capacity probes, real CMake feature guards, and mapping, +consent, identity, lifecycle and deployment regressions. The local CTest suite +should remain under one minute; each command has a 20-second timeout and the CI +job a 3-minute ceiling. Fix slow tests rather than silently dropping assertions, +disabling sanitizers, increasing timeouts, or claiming skipped tests passed. + +Use changed inputs to avoid unrelated work. Documentation must not rebuild five +applications. SDK/SDL pin changes require the real SDK, rumble and SDL integration +suites plus native builds. Shared production/build/resource changes require native +build/link/package/launch validation on all supported desktop architectures. +Platform-specific scripts require their affected platform. Test the selector for +renames, deletions, shared inputs and failure cases; unknown inputs fail toward +more validation, not less. Never use labels or manual dispatch as a substitute +for necessary automatic validation. + +Use compiler caches and the ordinary CMake build graph. Reuse upstream core +objects rather than duplicating standalone builds. Run the unchanged upstream +unit suite in the Linux application build tree. Do not duplicate it per platform +without a demonstrated need. Cache hits must never skip test execution, relinking +or package checks. Report cold/warm timings separately; caches can miss or expire. +Cancel obsolete runs, fail early, and upload application artifacts only when +requested. Do not add nightly builds, extra matrices or broader triggers without +an explicit need and a measured runtime/cost impact. + +## Automatic-job runtime budget + +Every automatic job, including native setup/build/package/launch work, has a +10-minute maximum. The focused suite keeps its stricter existing limits. A timed +out or cancelled native job is a failure, not a successful fast run. Do not raise +this budget, shard the same work into more billed jobs, switch to larger paid +runners, or delete checks to make the dashboard look fast. A green run above the +budget is not merge-ready. Verify actual job durations on the exact PR head. + +Native macOS/Windows smoke builds keep Release defines/runtime ABI but compile +C/C++ without expensive optimization. They still compile/link the complete real +application and execute every architecture/dependency/relocation/archive check. +Linux keeps optimized builds and the complete upstream unit suite; real Swift +SDK tests and sanitizer regressions remain unchanged. These smoke builds do not establish optimized +macOS/Windows code-generation or gameplay performance correctness. The existing +explicit artifact build uses normal optimized Release flags; local developer +build defaults are unchanged. Do not describe unoptimized smoke builds as release +qualification or distribute them as optimized releases. + +Cache the pinned Windows installer, do not upgrade already installed Homebrew +build dependencies, and use the CPUs already assigned to each standard runner. +Compiler-cache statistics must be visible; measure rather than assume a hit. +Cold-cache failures must be fixed, not hidden by reporting only a warm rerun. + +Keep build acceleration CI-local through `Tools/ci-native.cmake`; do not rewrite +upstream compiler defaults. Verify effective MSVC flags after upstream's +`FlagsOverride.cmake`, not only the command-line arguments. Reuse upstream's PCH +header on POSIX and its existing PCH implementation on Windows. Retain every +translation unit; do not use unity builds to mask missing includes or collisions. +Install the Qt components Dolphin actually requires, not the all-modules Qt +metapackage. Parallelize pinned submodule fetching. Keep compiler objects in a +bounded local cache restored once per job, rather than paying a remote-cache +round trip per translation unit. Cache only compiler outputs, never successful +test results or a prebuilt application. Both Linux configurations must continue +to configure/build/link separately, with the disabled one in a Swift-free image. + +## Claims and review + +A source check is not a native build; a fixture is not the actual SDK; an SDK test +is not Bluetooth hardware acceptance. Preserve native architecture, dependency, +relocation and exact-archive launch checks. Never trade away required coverage to +advertise a seconds-only result. CI changes must state what still runs, when it +runs, what is not covered, and which exact revision actually passed. Do not merge +based on an earlier revision's green result. Disclose AI assistance and leave +controller behavior, dependency pins and user-data safeguards unchanged unless +the task explicitly requires changing them. diff --git a/Docs/Switch2Kit.md b/Docs/Switch2Kit.md index 49a754a8811e..e105f24812d8 100644 --- a/Docs/Switch2Kit.md +++ b/Docs/Switch2Kit.md @@ -76,8 +76,10 @@ An automated callback test is not a physical rumble test. ## Applications and prerequisites -For development artifacts, open this repository's **Actions** tab and choose a successful -run for the revision being tested. The workflows and application artifacts are: +For development artifacts, open **Actions > Native Switch2Kit > Run workflow**, choose +the revision and the `macos`, `linux` or `windows` target, then use the successful run's +artifacts. PRs automatically qualify affected platforms but upload application archives +only when explicitly requested. The workflows and application artifacts are: | Platform | Workflow | Artifact | | --- | --- | --- | @@ -198,7 +200,9 @@ controller absent after a search, check adapter power/access, Sync mode, competi connections and the displayed status, then retry Find. Installing a dashboard or replacing system SDL is not a remedy. -The workflows check builds and extracted-package launch using private test settings. +See [testing and CI](Switch2KitCI.md) for the upstream unit-test baseline, focused CTest +commands, automatic change selection and compiler caching. Selected native workflows +check full builds and extracted-package launch using private test settings. They do not establish pristine first-use dialogs, downloaded-app approval, Bluetooth hardware or gameplay. Mapping and host regressions cover cancellation, backup/rollback, identity, saved consent, explicit stop and shutdown ordering; keep those checks when diff --git a/Docs/Switch2KitCI.md b/Docs/Switch2KitCI.md new file mode 100644 index 000000000000..8f5c45567d26 --- /dev/null +++ b/Docs/Switch2KitCI.md @@ -0,0 +1,79 @@ +# Testing the Switch2Kit additions + +## Upstream baseline + +Dolphin's checked-in unit-test system is Google Test plus CMake/CTest, under +`Source/UnitTests`. Its external Buildbot infrastructure is not a GitHub Actions +workflow that a fork inherits. This change preserves the existing upstream +`tests` / `unittests` targets and all their tests. The only extension to the parent +unit-test CMake file registers the Switch2Kit subdirectory on POSIX hosts. +There is no Switch2Kit-specific test opt-in. + +The full Linux backend-disabled build now uses `ENABLE_TESTS=ON` and runs the +ordinary `unittests` target, including the Switch2Kit regressions, in that same +build tree. This checks the upstream baseline and our additions together without +a second standalone core build. The image still contains no Swift, and its +existing no-SDK/runtime-dependency assertions remain mandatory. + +## Fast iteration uses the same CTest registration + +```sh +git submodule update --init --depth 1 Externals/Switch2Kit Externals/SDL/SDL +cmake -S Source/UnitTests/Switch2Kit -B build-switch2kit-tests +ctest --test-dir build-switch2kit-tests --output-on-failure --no-tests=error +``` + +This executes the existing mapping and host C++ harnesses with ASan/UBSan, real +CMake guard and deployment fixtures, connection-consent checks, and the real +adapter source's type/capacity probes under both Linux compilers. The same tests +run in ordinary POSIX test builds under `ENABLE_TESTS`, regardless of +`ENABLE_SWITCH2KIT`. The existing Windows exclusion reflects the POSIX compiler +harnesses, not a user-selectable test option. There is no separate runner or PyYAML +workflow-policy dependency. A local fixture pass does not qualify the actual SDK, +Qt UI, complete application, native packaging or Bluetooth hardware. + +## Automatic selection + +`Native Switch2Kit` always runs the small CTest suite and computes changes against +the PR event's base SHA, using the actual merge checkout. It does not use GitHub's +limited path-filter file list. Git failures fail the job. Renames include both +the removed and added path; unknown inputs conservatively request native checks. + +| Changed inputs | Additional automatic validation | +| --- | --- | +| Documentation only, or portable fixture tests only | No application builds; the CTest suite still runs | +| SDK/SDL pin or sources, or `.gitmodules` | Real Swift SDK, rumble and in-process SDL suites, then all native builds | +| Shared production code, CMake, dependencies or packaged resources | macOS arm64 + x86_64, Linux enabled + genuinely Swift-free disabled, Windows x64 | +| Platform-specific build/launch script or workflow | That platform's real build and package checks | +| Upstream unit-test sources | Linux application build and ordinary upstream unit suite | +| Change selector or parent workflow | SDK and every native job; validate the actual orchestration | + +The three platform workflows are reusable workflows called from the parent, +not independent PR triggers. A selected check must succeed: the aggregate +**Switch2Kit checks** job rejects a failed, cancelled or unexpectedly skipped job. +Make that aggregate check required in repository protection; `wiring` alone is +not sufficient. This PR does not change branch-protection settings. + +## Reuse work, not test results + +Native C/C++ builds use the standard CMake compiler-launcher mechanism with +sccache. Unchanged objects can be reused, but the real build graph, linking, +architecture/dependency checks and exact-archive launch tests still execute. +Linux disabled follows enabled so unchanged upstream objects can be reused +across the two configurations where compiler/cache keys match. SDK tests cache +the pinned Swift build, but always execute `swift test`, rumble and real SDL tests. + +Caches can miss or expire. GitHub cache scope also matters: a PR cache is not a +shared default-branch cache for future PRs. A manual run on `master` can prime +base-branch caches; it is optional and expensive, not run automatically by this +change. Cold native qualification remains a full build and is **not** a +seconds-only operation. Measure cold/warm native timings before claiming savings. +Job timeouts are safety ceilings, not benchmark results. + +Pull requests still construct and launch the exact application archives. They +do not upload successful application archives merely to test them. Failure +diagnostics are uploaded; successful distributables are uploaded only for an +explicit Actions **Run workflow** request. Select `macos`, `linux`, `windows`, +`sdk` or `all` in the parent workflow; `tests` is the cheap manual default. +The source-archive workflow remains manual because archiving adds no behavioral +coverage. No hardware qualification is inferred from any CI result. diff --git a/Source/UnitTests/CMakeLists.txt b/Source/UnitTests/CMakeLists.txt index 659d9e5b28fa..48c9c5d41cd5 100644 --- a/Source/UnitTests/CMakeLists.txt +++ b/Source/UnitTests/CMakeLists.txt @@ -23,3 +23,9 @@ endmacro() add_subdirectory(Common) add_subdirectory(Core) add_subdirectory(VideoCommon) + +# Run these source-level regressions even with the backend disabled. The +# harnesses require a POSIX compiler driver; native Windows is checked separately. +if(NOT WIN32) + add_subdirectory(Switch2Kit) +endif() diff --git a/Source/UnitTests/Switch2Kit/CMakeLists.txt b/Source/UnitTests/Switch2Kit/CMakeLists.txt new file mode 100644 index 000000000000..a6b676a479f9 --- /dev/null +++ b/Source/UnitTests/Switch2Kit/CMakeLists.txt @@ -0,0 +1,32 @@ +# Also usable alone: configure this directory to test the additions without +# building the unrelated emulator. The normal ENABLE_TESTS build includes it. +cmake_minimum_required(VERSION 3.25) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(DolphinSwitch2KitTests LANGUAGES NONE) + enable_testing() +endif() + +find_package(Python3 REQUIRED COMPONENTS Interpreter) +get_filename_component(DOLPHIN_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) + +function(add_switch2kit_test name script) + add_test(NAME Switch2Kit.${name} + COMMAND "${Python3_EXECUTABLE}" "${DOLPHIN_ROOT}/Tools/${script}" ${ARGN}) + set_tests_properties(Switch2Kit.${name} PROPERTIES TIMEOUT 20 LABELS Switch2Kit) +endfunction() + +add_switch2kit_test(ChangeSelection test_switch2kit_ci.py) +add_switch2kit_test(NativeExecution test_native_ci_execution.py) +add_switch2kit_test(Integration test_switch2kit.py) +add_switch2kit_test(AutoConnect test_switch2kit_autoconnect.py) +add_switch2kit_test(Mapping test_switch2kit_mapping.py --sanitize) +add_switch2kit_test(Host test_switch2kit_host.py --sanitize) +find_program(SWITCH2KIT_CLANGXX clang++ REQUIRED) +add_switch2kit_test(CapacityClang test_switch2kit_capacity.py) +set_tests_properties(Switch2Kit.CapacityClang PROPERTIES ENVIRONMENT "CXX=${SWITCH2KIT_CLANGXX}") +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + find_program(SWITCH2KIT_GXX g++ REQUIRED) + add_switch2kit_test(CapacityGCC test_switch2kit_capacity.py) + set_tests_properties(Switch2Kit.CapacityGCC PROPERTIES ENVIRONMENT "CXX=${SWITCH2KIT_GXX}") + add_switch2kit_test(Runtime test_switch2kit_runtime.py) +endif() diff --git a/Tools/build-switch2kit-linux.sh b/Tools/build-switch2kit-linux.sh index 3ff8871dbc00..fa38bb0aea99 100755 --- a/Tools/build-switch2kit-linux.sh +++ b/Tools/build-switch2kit-linux.sh @@ -6,13 +6,20 @@ if [[ $(uname -s) != Linux ]]; then echo 'This helper requires Linux.' >&2; exit for tool in cmake ninja git swift python3 pkg-config; do command -v "$tool" >/dev/null || { echo "Missing build tool: $tool" >&2; exit 1; } done -git submodule update --init --recursive +# Remaining arguments are ordinary CMake configure arguments, as in the Windows helper. +run=false +if [[ ${1:-} == --run ]]; then run=true; shift; fi +if [[ ${GITHUB_ACTIONS:-} == true ]]; then + python3 Tools/checkout_native.py --verify +else + git submodule update --init --recursive +fi cmake -S . -B build-switch2kit -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DENABLE_SWITCH2KIT=ON \ -DENABLE_SDL=ON -DENABLE_QT=ON -DUSE_SYSTEM_SDL3=OFF \ -DENABLE_TESTS=OFF -DENABLE_VULKAN=OFF -DENABLE_AUTOUPDATE=OFF \ - -DCMAKE_INSTALL_PREFIX="$PWD/build-switch2kit/install" + -DCMAKE_INSTALL_PREFIX="$PWD/build-switch2kit/install" "$@" cmake --build build-switch2kit --parallel "${S2K_BUILD_JOBS:-3}" cmake --install build-switch2kit printf 'Built: %s/build-switch2kit/install/bin/dolphin-emu\n' "$PWD" -if [[ ${1:-} == --run ]]; then exec "$PWD/build-switch2kit/install/bin/dolphin-emu"; fi +if "$run"; then exec "$PWD/build-switch2kit/install/bin/dolphin-emu"; fi diff --git a/Tools/build-switch2kit-windows.ps1 b/Tools/build-switch2kit-windows.ps1 index 46eaf724b1d4..c844f9573a88 100644 --- a/Tools/build-switch2kit-windows.ps1 +++ b/Tools/build-switch2kit-windows.ps1 @@ -1,4 +1,4 @@ -param([switch]$Run) +param([switch]$Run, [string[]]$CMakeArgs = @()) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest Set-Location (Join-Path $PSScriptRoot '..') @@ -39,20 +39,33 @@ Write-Output "Visual Studio: $vs" Write-Output "Swift target: $($target.target.triple)" # Keep runtime lookup local to this process and its launched application. $env:PATH = (($target.paths.runtimeLibraryPaths | Where-Object { Test-Path $_ }) -join ';') + ';' + $env:PATH -git submodule update --init --recursive +if ($env:GITHUB_ACTIONS -eq 'true') { + python Tools/checkout_native.py --verify +} else { + git submodule update --init --recursive +} if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cmake -S . -B build-switch2kit-windows -G Ninja -DCMAKE_BUILD_TYPE=Release ` -DENABLE_SWITCH2KIT=ON -DENABLE_SDL=ON -DENABLE_QT=ON -DUSE_SYSTEM_SDL3=OFF ` -DENABLE_TESTS=OFF -DENABLE_CLI_TOOL=OFF -DENABLE_AUTOUPDATE=OFF ` - "-DSWITCH2KIT_SWIFT=$swift" "-DSWITCH2KIT_SWIFTC=$swiftc" + "-DSWITCH2KIT_SWIFT=$swift" "-DSWITCH2KIT_SWIFTC=$swiftc" @CMakeArgs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -# Compile the real Swift/C++ WinRT library first. Fail on toolchain/SDK -# incompatibility before spending time on Dolphin's other dependencies; do not -# bypass the Microsoft STL or Dolphin compiler guards. This target is reused by -# the application build, with the same SwiftPM configuration and scratch path. -cmake --build build-switch2kit-windows --target Switch2KitCBuild --parallel 3 +# Preserve the developer/explicit-artifact SDK-first failure path. Automatic +# builds request the SDK and complete application in the same Ninja graph, so +# independent native objects can compile while Swift runs. The existing graph +# still orders every SDK consumer and staging step, and either failure is fatal. +$parallelTargets = @() +$buildJobs = [Environment]::ProcessorCount +if ($env:GITHUB_ACTIONS -eq 'true' -and $env:GITHUB_EVENT_NAME -ne 'workflow_dispatch') { + $parallelTargets = @('dolphin-emu_autogen', 'dolphin-emu') + # Match Ninja's normal CPU+2 queue headroom on the same standard runner. + $buildJobs += 2 +} +cmake --build build-switch2kit-windows --target Switch2KitCBuild @parallelTargets --parallel $buildJobs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -cmake --build build-switch2kit-windows --target dolphin-emu --parallel 3 +# This is an incremental verification after an automatic combined build, not a +# second SDK scratch tree or a cached application. Developer behavior is unchanged. +cmake --build build-switch2kit-windows --target dolphin-emu --parallel $buildJobs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $app = Join-Path $PWD 'build-switch2kit-windows/Binaries/Dolphin.exe' if (-not (Test-Path $app) -or -not (Test-Path (Join-Path (Split-Path $app) 'Switch2KitC.dll'))) { diff --git a/Tools/checkout_native.py b/Tools/checkout_native.py new file mode 100644 index 000000000000..a0b4ef210e57 --- /dev/null +++ b/Tools/checkout_native.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dolphin Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Fetch the native build's exact gitlinks concurrently, including pinned commits.""" +import argparse +import configparser +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path, PurePosixPath +import subprocess +import sys + + +def git(root, *arguments): + result = subprocess.run(['git', '-C', str(root), *arguments], check=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, timeout=120) + return result.stdout + + +def native_modules(root, platform): + if platform not in ('linux', 'darwin', 'win32'): + raise ValueError(f'Unsupported native host: {platform}') + config = configparser.ConfigParser(interpolation=None) + with (root / '.gitmodules').open() as file: + config.read_file(file) + modules = [] + for section in config.sections(): + name = section.removeprefix('submodule "').removesuffix('"') + path = config[section]['path'] + relative = PurePosixPath(path) + if relative.is_absolute() or '..' in relative.parts: + raise ValueError(f'Unsafe submodule path: {path}') + active = path != 'Externals/libadrenotools' # Android only. + if platform != 'win32' and path in ('Externals/Qt', 'Externals/FFmpeg-bin'): + active = False # Windows binaries, not POSIX build dependencies. + modules.append((name, path, active)) + return modules + + +def checkout(root, platform): + modules = native_modules(root, platform) + paths = [path for _, path, active in modules if active] + if not paths: + raise ValueError('No native dependencies selected') + # Serialize parent configuration writes before starting independent workers. + # Build helpers use --verify in CI rather than fetching unrelated modules. + for name, _, active in modules: + git(root, 'config', f'submodule.{name}.active', str(active).lower()) + git(root, 'submodule', 'init', '--', *paths) + pins = native_pins(root, paths) + + def update(path): + # --jobs only parallelizes the initial clones; fetching older gitlink + # commits afterwards was still serial. Parallelize each complete update. + output = git(root, 'submodule', 'update', '--init', '--recursive', + '--depth=1', '--checkout', '--', path) + actual = git(root / path, 'rev-parse', 'HEAD').strip() + if actual != pins[path]: + raise ValueError(f'{path}: expected {pins[path]}, got {actual}') + print(f'{path}: {actual}', flush=True) + return output + + with ThreadPoolExecutor(max_workers=8) as workers: + list(workers.map(update, paths)) + + +def native_pins(root, paths): + entries = git(root, 'ls-tree', '-z', 'HEAD', '--', *paths).split('\0') + pins = {} + for entry in filter(None, entries): + metadata, path = entry.split('\t', 1) + mode, kind, sha = metadata.split() + if mode != '160000' or kind != 'commit': + raise ValueError(f'Expected a pinned gitlink: {path}') + pins[path] = sha + if set(pins) != set(paths): + raise ValueError('Selected dependencies do not match committed gitlinks') + + return pins + + +def verify_checkout(root, platform): + """Reject missing/stale pins without fetching, including nested gitlinks.""" + paths = [path for _, path, active in native_modules(root, platform) if active] + if not paths: + raise ValueError('No native dependencies selected') + pins = native_pins(root, paths) + for path, expected in pins.items(): + # git -C on an uninitialized submodule can resolve the parent repository. + # Require the submodule's own .git before comparing its HEAD. + if not (root / path / '.git').exists(): + raise ValueError(f'Uninitialized native dependency: {path}') + actual = git(root / path, 'rev-parse', 'HEAD').strip() + if actual != expected: + raise ValueError(f'{path}: expected {expected}, got {actual}') + status = git(root, 'submodule', 'status', '--recursive', '--', *paths) + for line in status.splitlines(): + if not line.startswith(' '): + raise ValueError(f'Uninitialized or stale recursive gitlink: {line}') + print(f'Verified {len(pins)} native dependencies and their recursive gitlinks', flush=True) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--verify', action='store_true', help='Verify pins without fetching') + args = parser.parse_args() + try: + action = verify_checkout if args.verify else checkout + action(Path(__file__).resolve().parents[1], sys.platform) + except subprocess.CalledProcessError as error: + print(error.stdout, file=sys.stderr) + raise SystemExit(error.returncode) diff --git a/Tools/ci-native.cmake b/Tools/ci-native.cmake new file mode 100644 index 000000000000..bd35e8ecb9f1 --- /dev/null +++ b/Tools/ci-native.cmake @@ -0,0 +1,301 @@ +# CI-only build acceleration, loaded through CMAKE_PROJECT_dolphin-emu_INCLUDE. +# Keep upstream source/build defaults and the complete native build graph intact. +if(NOT "$ENV{GITHUB_ACTIONS}" STREQUAL "true") + message(FATAL_ERROR "Tools/ci-native.cmake is only for GitHub Actions builds") +endif() + +# project() has now loaded upstream FlagsOverride.cmake. Command-line -D flags +# alone are shadowed by that file's normal variables on MSVC. +if(NOT "$ENV{GITHUB_EVENT_NAME}" STREQUAL "workflow_dispatch") + if(MSVC) + set(CMAKE_C_FLAGS_RELEASE "/Od /Ob0 /DNDEBUG /Z7") + set(CMAKE_CXX_FLAGS_RELEASE "/Od /Ob0 /DNDEBUG /Z7") + # Preserve the setup hook's child-directory options. The deferred target + # pass below is authoritative when later target options would override them. + add_compile_options( + "$<$,$>:/Od>" + "$<$,$>:/Ob0>" + ) + elseif(APPLE) + set(CMAKE_C_FLAGS_RELEASE "-O0 -DNDEBUG") + set(CMAKE_CXX_FLAGS_RELEASE "-O0 -DNDEBUG") + elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") + # Keep the complete upstream Linux suite optimized. Explicit artifacts keep + # the upstream Release optimizer; automatic qualification uses -O1. + set(CMAKE_C_FLAGS_RELEASE "-O1 -DNDEBUG") + set(CMAKE_CXX_FLAGS_RELEASE "-O1 -DNDEBUG") + endif() +endif() +message(STATUS "Native CI C Release flags: ${CMAKE_C_FLAGS_RELEASE}") +message(STATUS "Native CI C++ Release flags: ${CMAKE_CXX_FLAGS_RELEASE}") + +# Keep all configure probes as real compilations AND links. On Windows, use +# the runner's native LLVM linker for automatic smoke builds, including probes; +# MSVC still compiles every C/C++ source and owns Dolphin's shared PCH. +if(CMAKE_HOST_WIN32 AND MSVC AND NOT "$ENV{GITHUB_EVENT_NAME}" STREQUAL "workflow_dispatch") + if(CMAKE_VERSION VERSION_LESS 3.29) + message(FATAL_ERROR "Native Windows CI requires CMake 3.29 or newer for LLD") + endif() + find_program(CMAKE_LINKER_LLD NAMES lld-link HINTS "$ENV{ProgramFiles}/LLVM/bin" REQUIRED) + set(CMAKE_C_USING_LINKER_LLD "${CMAKE_LINKER_LLD}") + set(CMAKE_CXX_USING_LINKER_LLD "${CMAKE_LINKER_LLD}") + list(APPEND CMAKE_TRY_COMPILE_PLATFORM_VARIABLES CMAKE_LINKER_LLD) + set(CMAKE_LINKER_TYPE LLD) + # Test the same runtime configuration as the real application, not a separate + # Debug link with an incremental PDB/manifest cycle for every tiny probe. + set(CMAKE_TRY_COMPILE_CONFIGURATION Release) + # Upstream appends /DEBUG to every executable link, including feature probes. + # Tiny configure executables still compile and link, but need neither a PDB + # nor an application manifest. These check-only options never reach targets. + list(APPEND CMAKE_REQUIRED_LINK_OPTIONS /DEBUG:NONE /MANIFEST:NO) + message(STATUS "Native CI Windows linker: ${CMAKE_LINKER_LLD}") +endif() + +# Clang's native ELF linker avoids the serial GNU ld tail for the complete +# Linux application and upstream test executable. Keep real executable feature +# probes, and leave explicit artifacts, nested SDK builds and GCC unchanged. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang" + AND DEFINED ENV{S2K_CI_LINUX_LINKER} + AND NOT "$ENV{GITHUB_EVENT_NAME}" STREQUAL "workflow_dispatch") + set(_switch2kit_linux_lld "$ENV{S2K_CI_LINUX_LINKER}") + if(NOT IS_ABSOLUTE "${_switch2kit_linux_lld}" OR NOT EXISTS "${_switch2kit_linux_lld}") + message(FATAL_ERROR "Native CI requires the explicitly selected Linux linker") + endif() + add_link_options("-fuse-ld=${_switch2kit_linux_lld}") + list(APPEND CMAKE_REQUIRED_LINK_OPTIONS "-fuse-ld=${_switch2kit_linux_lld}") + message(STATUS "Native CI Linux linker: ${_switch2kit_linux_lld}") +endif() + +# Use a CI-specific variable, not CMAKE_*_COMPILER_LAUNCHER in the job's +# environment: those standard variables also reach every nested SDK/fixture +# configuration and try_compile project. Clearing a normal variable alone does +# not clear that environment or its cache entry. Keep actual feature/link probes. +set(_switch2kit_c_launcher "${CMAKE_C_COMPILER_LAUNCHER}") +set(_switch2kit_cxx_launcher "${CMAKE_CXX_COMPILER_LAUNCHER}") +if(DEFINED ENV{S2K_CI_COMPILER_CACHE}) + if(NOT _switch2kit_c_launcher) + set(_switch2kit_c_launcher "$ENV{S2K_CI_COMPILER_CACHE}") + endif() + if(NOT _switch2kit_cxx_launcher) + set(_switch2kit_cxx_launcher "$ENV{S2K_CI_COMPILER_CACHE}") + endif() +endif() +foreach(language C CXX) + set(CMAKE_${language}_COMPILER_LAUNCHER "" CACHE STRING "Native CI target-only cache" FORCE) + set(CMAKE_${language}_COMPILER_LAUNCHER "") + unset(ENV{CMAKE_${language}_COMPILER_LAUNCHER}) +endforeach() + +# Windows already shares upstream's PCH. On POSIX, compile the same upstream +# header once per target instead of parsing it for every translation unit. +# Per-target PCHs preserve each target's own defines, include paths and flags. +# Do not use unity builds (which change translation-unit boundaries), touch the +# production sources, replace libraries, or exclude a source from compilation. +# Upstream splits its unit suite into object libraries. Accelerate their C++ +# parsing too, rather than only the two sources in the final tests executable. +function(switch2kit_ci_test_targets directory output) + get_property(targets DIRECTORY "${directory}" PROPERTY BUILDSYSTEM_TARGETS) + get_property(children DIRECTORY "${directory}" PROPERTY SUBDIRECTORIES) + foreach(child IN LISTS children) + switch2kit_ci_test_targets("${child}" child_targets) + list(APPEND targets ${child_targets}) + endforeach() + set(${output} "${targets}" PARENT_SCOPE) +endfunction() + +function(switch2kit_ci_precompile_headers) + # The pinned shader compiler supplies its own PCH for these exact sources. + # Its parser headers dominated cold compilation; compile that same upstream + # header once, without unity builds or changing the shader compiler's sources. + if(TARGET glslang) + get_target_property(glslang_source glslang SOURCE_DIR) + target_precompile_headers(glslang PRIVATE "${glslang_source}/MachineIndependent/pch.h") + endif() + if(TARGET dolphin-emu AND ENABLE_QT AND + NOT "$ENV{GITHUB_EVENT_NAME}" STREQUAL "workflow_dispatch") + # moc reads the Qt target's sources; it need not wait for every linked + # library to finish compiling. Preserve explicit generated/staging targets + # and any existing autogen dependencies. CMake still owns source generation + # and the application's compile/link dependency graph. + get_target_property(qt_dependencies dolphin-emu MANUALLY_ADDED_DEPENDENCIES) + if(qt_dependencies) + set_property(TARGET dolphin-emu APPEND PROPERTY AUTOGEN_TARGET_DEPENDS + ${qt_dependencies}) + endif() + # This upstream generated header is a core source, not a Qt source. Keep + # the file prerequisite without waiting for core's compiled static library. + if(TARGET core) + get_target_property(core_binary core BINARY_DIR) + get_target_property(core_sources core SOURCES) + if("AchievementApprovedHash.h" IN_LIST core_sources) + set_property(TARGET dolphin-emu APPEND PROPERTY AUTOGEN_TARGET_DEPENDS + "${core_binary}/AchievementApprovedHash.h") + endif() + endif() + set_property(TARGET dolphin-emu PROPERTY AUTOGEN_ORIGIN_DEPENDS OFF) + endif() + if(MSVC) + # DolphinQt enables RTTI and does not use the core's shared /GR- PCH. + # Give it its own PCH, with its actual Qt flags, as on POSIX. Do not mix + # CMake's PCH with an existing manual or target-provided implementation. + if(TARGET dolphin-emu AND ENABLE_QT) + get_target_property(qt_links dolphin-emu LINK_LIBRARIES) + get_target_property(qt_pch dolphin-emu PRECOMPILE_HEADERS) + get_target_property(qt_reuse_pch dolphin-emu PRECOMPILE_HEADERS_REUSE_FROM) + if(NOT "use_pch" IN_LIST qt_links AND NOT qt_pch AND NOT qt_reuse_pch) + target_precompile_headers(dolphin-emu PRIVATE + "$<$:${PROJECT_SOURCE_DIR}/Source/PCH/pch.h>" + "$<$:>") + endif() + endif() + return() + endif() + set(pch_targets common audiocommon inputcommon videocommon discio core uicommon + videoogl videonull videosoftware videometal videovulkan + dolphin-tool dolphin-emu) + if(TARGET tests) + get_target_property(test_dir tests SOURCE_DIR) + switch2kit_ci_test_targets("${test_dir}" test_targets) + list(APPEND pch_targets ${test_targets}) + endif() + foreach(target IN LISTS pch_targets) + if(TARGET ${target}) + get_target_property(kind ${target} TYPE) + if(NOT kind MATCHES "^(EXECUTABLE|STATIC_LIBRARY|SHARED_LIBRARY|MODULE_LIBRARY|OBJECT_LIBRARY)$") + continue() + endif() + get_target_property(existing_pch ${target} PRECOMPILE_HEADERS) + get_target_property(reuse_pch ${target} PRECOMPILE_HEADERS_REUSE_FROM) + if(existing_pch OR reuse_pch) + continue() + endif() + # Upstream gives some files different flags (e.g. ARM crypto ISA flags). + # They still compile normally; a target-wide PCH cannot represent those + # per-source options. Resolve properties in the target's source directory. + get_target_property(source_dir ${target} SOURCE_DIR) + get_target_property(sources ${target} SOURCES) + foreach(source IN LISTS sources) + if(source MATCHES "\\$<") + continue() + endif() + if(NOT IS_ABSOLUTE "${source}") + set(source "${source_dir}/${source}") + endif() + foreach(property COMPILE_FLAGS COMPILE_OPTIONS COMPILE_DEFINITIONS) + get_source_file_property(value "${source}" DIRECTORY "${source_dir}" ${property}) + if(value) + set_source_files_properties("${source}" DIRECTORY "${source_dir}" + PROPERTIES SKIP_PRECOMPILE_HEADERS ON) + endif() + endforeach() + endforeach() + # Most upstream cases are one-source object libraries. Share a PCH only + # across cases with the same target properties, directory flags and exact + # dependency usage requirements. Reusing the tests executable would create + # a dependency cycle: it links these cases. Use the first matching case. + set(pch_key "") + if(kind STREQUAL "OBJECT_LIBRARY" AND target IN_LIST test_targets) + set(signature "") + foreach(property COMPILE_OPTIONS COMPILE_DEFINITIONS INCLUDE_DIRECTORIES + LINK_LIBRARIES CXX_STANDARD CXX_STANDARD_REQUIRED CXX_EXTENSIONS + POSITION_INDEPENDENT_CODE CXX_VISIBILITY_PRESET + VISIBILITY_INLINES_HIDDEN COMPILE_FLAGS AUTOMOC AUTOUIC) + get_target_property(value ${target} ${property}) + string(APPEND signature "|${property}=${value}") + endforeach() + foreach(variable CMAKE_CXX_FLAGS CMAKE_CXX_FLAGS_DEBUG CMAKE_CXX_FLAGS_RELEASE + CMAKE_CXX_FLAGS_RELWITHDEBINFO CMAKE_CXX_FLAGS_MINSIZEREL + CMAKE_INCLUDE_CURRENT_DIR CMAKE_CXX_SCAN_FOR_MODULES) + get_directory_property(value DIRECTORY "${source_dir}" DEFINITION ${variable}) + string(APPEND signature "|${variable}=${value}") + if(variable STREQUAL "CMAKE_INCLUDE_CURRENT_DIR" AND value) + string(APPEND signature "|source=${source_dir}") + endif() + endforeach() + # Target-context expressions may evaluate differently despite matching + # strings. Such targets retain their own PCH instead of assuming equality. + if(NOT signature MATCHES "TARGET_PROPERTY|TARGET_NAME|TARGET_OBJECTS") + string(SHA256 pch_key "${signature}") + endif() + endif() + if(pch_key AND DEFINED pch_provider_${pch_key}) + target_precompile_headers(${target} REUSE_FROM ${pch_provider_${pch_key}}) + else() + target_precompile_headers(${target} PRIVATE + "$<$:${PROJECT_SOURCE_DIR}/Source/PCH/pch.h>") + if(pch_key) + set(pch_provider_${pch_key} ${target}) + endif() + endif() + endif() + endforeach() + if(TARGET dolphin-emu AND ENABLE_QT) + target_precompile_headers(dolphin-emu PRIVATE + "$<$:>") + endif() +endfunction() + +function(switch2kit_ci_cache_targets directory) + get_property(children DIRECTORY "${directory}" PROPERTY SUBDIRECTORIES) + foreach(child IN LISTS children) + switch2kit_ci_cache_targets("${child}") + endforeach() + get_property(targets DIRECTORY "${directory}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(target IN LISTS targets) + get_target_property(kind ${target} TYPE) + if(kind MATCHES "^(EXECUTABLE|STATIC_LIBRARY|SHARED_LIBRARY|MODULE_LIBRARY|OBJECT_LIBRARY)$") + get_target_property(pch ${target} PRECOMPILE_HEADERS) + get_target_property(reuse_pch ${target} PRECOMPILE_HEADERS_REUSE_FROM) + get_target_property(links ${target} LINK_LIBRARIES) + # Source/PCH implements MSVC's /Yc and /Yu manually, through build_pch + # and the use_pch interface. PRECOMPILE_HEADERS does not describe it. + set(manual_pch FALSE) + if(MSVC AND (target STREQUAL "build_pch" OR "use_pch" IN_LIST links)) + set(manual_pch TRUE) + endif() + if(pch OR reuse_pch OR manual_pch) + # Explicitly clear inherited launchers as well. Merely doing nothing + # left SDL and child-project PCH consumers going through sccache. + set_target_properties(${target} PROPERTIES + C_COMPILER_LAUNCHER "" CXX_COMPILER_LAUNCHER "") + else() + set_target_properties(${target} PROPERTIES + C_COMPILER_LAUNCHER "${_switch2kit_c_launcher}" + CXX_COMPILER_LAUNCHER "${_switch2kit_cxx_launcher}") + endif() + if(NOT "$ENV{GITHUB_EVENT_NAME}" STREQUAL "workflow_dispatch") + # Let CMake remove only unnecessary compile-order edges between static + # and object libraries. It retains explicit add_dependencies(), generated + # source prerequisites, custom-command side effects and final link inputs. + # Never clear MANUALLY_ADDED_DEPENDENCIES or edit Ninja's generated graph. + if(kind MATCHES "^(STATIC_LIBRARY|OBJECT_LIBRARY)$") + set_property(TARGET ${target} PROPERTY OPTIMIZE_DEPENDENCIES ON) + endif() + if(MSVC) + # SHELL keeps this final pair together even when an inherited /Od or + # /Ob0 appeared earlier: CMake otherwise de-duplicates the last copy. + # Apply to the PCH producer and consumers alike; keep Debug, CRT, + # warning, architecture, debug-information and sanitizer flags intact. + target_compile_options(${target} PRIVATE + "$<$,$>:SHELL:/Od /Ob0>") + endif() + endif() + if(NOT MSVC AND NOT "$ENV{GITHUB_EVENT_NAME}" STREQUAL "workflow_dispatch") + if(APPLE) + # Objective-C languages are enabled after the project hook. Their + # Release flags otherwise retain -O3 even when C/C++ use -O0. + target_compile_options(${target} PRIVATE + "$<$,$>:-O0>" + "$<$,$>:-g1>") + endif() + # Upstream appends -ggdb even in Release. Keep line-level backtraces, + # without emitting full type debug information for every smoke object. + # Append after upstream initialization; never change sanitizer options. + target_compile_options(${target} PRIVATE + "$<$,$>:-g1>") + endif() + endif() + endforeach() +endfunction() +cmake_language(DEFER CALL switch2kit_ci_precompile_headers) +cmake_language(DEFER CALL switch2kit_ci_cache_targets "${PROJECT_SOURCE_DIR}") diff --git a/Tools/switch2kit_ci.py b/Tools/switch2kit_ci.py new file mode 100644 index 000000000000..aa3a819f7586 --- /dev/null +++ b/Tools/switch2kit_ci.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dolphin Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Select validation from changed inputs, not from labels or guessed coverage. + +PRs compare their merge result with the event's base SHA. Renames are expanded +into deletion/addition pairs so moving a production file to Docs cannot hide it. +Unknown files conservatively select all native builds. Git errors fail the job. +""" +import argparse +import os +from pathlib import Path +import subprocess + +TARGETS = frozenset(('sdk', 'macos', 'linux', 'windows')) +PLATFORM_TOOLS = { + 'Tools/build-switch2kit-linux.sh': 'linux', + 'Tools/build-switch2kit-windows.ps1': 'windows', + 'Tools/test_switch2kit_windows_launch.ps1': 'windows', + 'Tools/test_switch2kit_bundle.py': 'macos', + 'Tools/mac-codesign.sh': 'macos', +} + + +def select_checks(paths): + selected = set() + for path in paths: + if path == '.github/workflows/switch2kit-desktop.yml': + continue # Source archiving has no compilation or behavioral coverage. + if path in PLATFORM_TOOLS: + selected.add(PLATFORM_TOOLS[path]) + elif path in ('.github/workflows/switch2kit-macos.yml', + '.github/workflows/switch2kit-linux.yml', + '.github/workflows/switch2kit-windows.yml'): + selected.add(Path(path).stem.removeprefix('switch2kit-')) + elif path in ('Tools/switch2kit_ci.py', 'Tools/test_switch2kit_ci.py', + '.github/workflows/native-switch2kit.yml', '.gitmodules'): + selected.update(TARGETS) + elif path == 'Externals/Switch2Kit' or path.startswith(('Externals/Switch2Kit/', + 'Externals/SDL/')): + selected.update(TARGETS) + elif path.startswith(('Source/UnitTests/', 'Tools/switch2kit/')): + # The normal upstream test target is executed in the Linux native job. + if path.startswith('Source/UnitTests/') and not path.startswith('Source/UnitTests/Switch2Kit/'): + selected.add('linux') + elif path.startswith('Tools/test_switch2kit') and path.endswith('.py'): + continue # These portable tests always execute in the CTest job. + elif path.startswith(('Docs/', '.tx/')) or path in ( + 'Readme.md', 'Contributing.md', 'CODE_OF_CONDUCT.md', 'COPYING', + 'AGENTS.md', '.mailmap', '.git-blame-ignore-revs', '.editorconfig'): + continue + else: + # Includes Source/Core, CMake, resources, other Externals and unknown + # build inputs. Do not assume a platform-independent edit is harmless. + selected.update(('macos', 'linux', 'windows')) + return selected + + +def changed_paths(base, head='HEAD'): + result = subprocess.run(['git', 'diff', '--no-renames', '--name-only', '-z', base, head], + check=True, stdout=subprocess.PIPE, timeout=15) + return [os.fsdecode(path) for path in result.stdout.split(b'\0') if path] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--base') + parser.add_argument('--target', choices=('tests', 'all', *sorted(TARGETS)), default='tests') + args = parser.parse_args() + selected = select_checks(changed_paths(args.base)) if args.base else ( + set(TARGETS) if args.target == 'all' else set() if args.target == 'tests' else {args.target}) + # Outputs are derived only from constant target names, never from filenames. + output = ''.join(f'{target}={str(target in selected).lower()}\n' for target in sorted(TARGETS)) + print(output, end='') + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as stream: + stream.write(output) + + +if __name__ == '__main__': + main() diff --git a/Tools/test_native_ci_execution.py b/Tools/test_native_ci_execution.py new file mode 100644 index 000000000000..6a906cedac20 --- /dev/null +++ b/Tools/test_native_ci_execution.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dolphin Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Execute the native-CI configure/cache/PCH boundaries, without Dolphin or Swift.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import textwrap +import unittest + +import checkout_native + +PRESET = Path(__file__).with_name('ci-native.cmake').resolve() + + +def run(*args, cwd, env=None, ok=True): + result = subprocess.run([str(arg) for arg in args], cwd=cwd, env=env, + text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=15) + if ok and result.returncode: + raise AssertionError(f'{args}:\n{result.stdout}') + return result + + +def write(root, name, content): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content), encoding='utf-8') + return path + + +class NativeExecutionTests(unittest.TestCase): + def test_real_probes_pch_generated_sources_and_test_execution(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + wrapper = write(root, 'cache.py', '''\ + import json, os, sys + with open(os.environ['CACHE_CALLS'], 'a') as out: + out.write(json.dumps([os.getcwd(), *sys.argv[1:]]) + '\\n') + os.execv(sys.argv[1], sys.argv[1:]) + ''') + launcher = f'{sys.executable};{wrapper}' + calls = root / 'cache-calls.jsonl' + env = dict(os.environ, GITHUB_ACTIONS='true', CACHE_CALLS=str(calls), + S2K_CI_COMPILER_CACHE=launcher, + CMAKE_C_COMPILER_LAUNCHER=launcher, + CMAKE_CXX_COMPILER_LAUNCHER=launcher) + # Use the selected host compiler, not a fake successful compiler. + cc = os.environ.get('CC') or shutil.which('cc') + cxx = os.environ.get('CXX') or shutil.which('c++') + self.assertTrue(cc and cxx, 'A real C and C++ compiler is required') + write(root, 'Source/PCH/pch.h', '#pragma once\n#include \n' + '#ifdef CASE_VALUE\nconstexpr int value_from_pch = CASE_VALUE;\n#endif\n') + write(root, 'common.cpp', 'int common_value() { return 10; }\n') + write(root, 'Source/UnitTests/CMakeLists.txt', '''\ + add_library(upstream_cases OBJECT case.cpp) + add_library(matching_cases OBJECT matching.cpp) + add_library(different_cases OBJECT different.cpp) + target_compile_definitions(upstream_cases PRIVATE CASE_VALUE=10) + target_compile_definitions(matching_cases PRIVATE CASE_VALUE=10) + target_compile_definitions(different_cases PRIVATE CASE_VALUE=22) + add_executable(tests EXCLUDE_FROM_ALL main.cpp) + target_link_libraries(tests PRIVATE upstream_cases matching_cases different_cases) + add_custom_target(unittests DEPENDS tests) + ''') + write(root, 'Source/UnitTests/case.cpp', 'int tested_value() { return value_from_pch; }\n') + write(root, 'Source/UnitTests/matching.cpp', 'int matching_value() { return value_from_pch; }\n') + write(root, 'Source/UnitTests/different.cpp', 'int different_value() { return value_from_pch; }\n') + write(root, 'Source/UnitTests/main.cpp', + 'int tested_value(); int matching_value(); int different_value(); ' + 'int main() { return tested_value() + matching_value() + different_value() == 42 ? 0 : 1; }\n') + write(root, 'special.cpp', '''\ + #ifndef PER_SOURCE + #error per-source options lost + #endif + int special_value() { return 11; } + ''') + write(root, 'generated.cpp.in', 'int generated_value() { return 12; }\n') + write(root, 'plain.c', '''\ + #ifndef NDEBUG + #error Release defines lost + #endif + #if defined(__linux__) && !defined(__OPTIMIZE__) + #error Linux must remain optimized + #endif + int plain_value(void) { return 9; } + ''') + write(root, 'external.cpp', 'int external_value() { return 0; }\n') + write(root, 'MachineIndependent/pch.h', '#pragma once\n#include \n') + write(root, 'main.cpp', '''\ + #include + extern "C" int plain_value(void); + int common_value(); int special_value(); int generated_value(); + int external_value(); + int main() { + std::ofstream("executed.txt", std::ios::app) << "executed\\n"; + return plain_value() + common_value() + special_value() + + generated_value() + external_value() == 42 ? 0 : 1; + } + ''') + # A separate CMake process models SDK and try_compile environments. + write(root, 'probe/CMakeLists.txt', '''\ + cmake_minimum_required(VERSION 3.25) + project(NestedProbe LANGUAGES C CXX) + include(CheckCSourceCompiles) + include(CheckCXXSourceCompiles) + check_c_source_compiles("int main(void) { return 0; }" REAL_C) + check_cxx_source_compiles("int main() { return 0; }" REAL_CXX) + check_c_source_compiles("extern int missing_symbol(void); int main(void) { return missing_symbol(); }" BAD_LINK) + if(NOT REAL_C OR NOT REAL_CXX OR BAD_LINK) + message(FATAL_ERROR "Real configure/link probes were bypassed") + endif() + ''') + write(root, 'child/CMakeLists.txt', '''\ + project(Child LANGUAGES C CXX) + include(CheckCSourceCompiles) + check_c_source_compiles("int main(void) { return 0; }" CHILD_C) + if(NOT CHILD_C) + message(FATAL_ERROR "Child probe failed") + endif() + add_library(plain STATIC ../plain.c) + ''') + write(root, 'CMakeLists.txt', '''\ + cmake_minimum_required(VERSION 3.25) + project(dolphin-emu LANGUAGES C CXX) + add_subdirectory(child) + add_subdirectory(Source/UnitTests) + execute_process(COMMAND "${CMAKE_COMMAND}" -S "${CMAKE_SOURCE_DIR}/probe" + -B "${CMAKE_BINARY_DIR}/probe" -G Ninja + "-DCMAKE_C_COMPILER=${CMAKE_C_COMPILER}" + "-DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}" + RESULT_VARIABLE probe_result OUTPUT_VARIABLE probe_output ERROR_VARIABLE probe_error) + if(probe_result) + message(FATAL_ERROR "Nested probe failed: ${probe_output}${probe_error}") + endif() + add_custom_command(OUTPUT "${CMAKE_BINARY_DIR}/generated.cpp" + COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/generated.cpp.in" + "${CMAKE_BINARY_DIR}/generated.cpp" DEPENDS generated.cpp.in) + add_library(common STATIC common.cpp special.cpp "${CMAKE_BINARY_DIR}/generated.cpp") + target_link_libraries(common PRIVATE plain) + set_source_files_properties(special.cpp PROPERTIES COMPILE_OPTIONS -DPER_SOURCE=1) + # Model a child resetting launchers, like SDL's own setup. + set_property(TARGET common PROPERTY CXX_COMPILER_LAUNCHER "$ENV{S2K_CI_COMPILER_CACHE}") + add_library(glslang STATIC external.cpp) + add_library(external_pch STATIC external.cpp) + target_precompile_headers(external_pch PRIVATE "${CMAKE_SOURCE_DIR}/Source/PCH/pch.h") + set_property(TARGET external_pch PROPERTY CXX_COMPILER_LAUNCHER "$ENV{S2K_CI_COMPILER_CACHE}") + add_executable(check main.cpp) + target_link_libraries(check PRIVATE plain common external_pch) + # Upstream adds this after project(). The CI setting must follow it. + target_compile_options(common PRIVATE -ggdb) + enable_testing() + add_test(NAME actual_execution COMMAND check) + ''') + for event, expected_opt in [('pull_request', '-O1'), ('workflow_dispatch', '-O3')]: + with self.subTest(event=event): + env['GITHUB_EVENT_NAME'] = event + build = root / event + calls.unlink(missing_ok=True) + configure = ['cmake', '-S', root, '-B', build, '-G', 'Ninja', + '-DCMAKE_BUILD_TYPE=Release', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON', + f'-DCMAKE_C_COMPILER={cc}', f'-DCMAKE_CXX_COMPILER={cxx}', + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={PRESET}'] + run(*configure, cwd=root, env=env) + # Legacy standard launchers can reach root ABI detection, + # which precedes project()'s include. They must not reach any + # subsequent feature probe or nested configure. Workflows now + # set only S2K_CI_COMPILER_CACHE, avoiding even these two calls. + initial = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else [] + for request in initial: + self.assertTrue(request[0].startswith(str(build / 'CMakeFiles'))) + self.assertIn(Path(request[-1]).name, + ['CMakeCCompilerABI.c', 'CMakeCXXCompilerABI.cpp']) + calls.unlink(missing_ok=True) + commands = json.loads((build / 'compile_commands.json').read_text()) + c_command = next(c['command'] for c in commands if c['file'].endswith('plain.c')) + if sys.platform.startswith('linux'): + self.assertIn(expected_opt, c_command) + common_command = next(c['command'] for c in commands if c['file'].endswith('common.cpp')) + self.assertIn('cmake_pch', common_command) + case_command = next(c['command'] for c in commands if c['file'].endswith('case.cpp')) + self.assertIn('cmake_pch', case_command) + matching = next(c['command'] for c in commands if c['file'].endswith('matching.cpp')) + different = next(c['command'] for c in commands if c['file'].endswith('different.cpp')) + self.assertIn('upstream_cases.dir/cmake_pch', matching) + self.assertIn('different_cases.dir/cmake_pch', different) + self.assertNotIn('upstream_cases.dir/cmake_pch', different) + order = run('ninja', '-C', build, '-t', 'query', + 'cmake_object_order_depends_target_common', cwd=root, env=env).stdout + if event == 'pull_request': + self.assertNotIn('cmake_object_order_depends_target_plain', order) + else: + self.assertIn('cmake_object_order_depends_target_plain', order) + if event == 'pull_request': + self.assertLess(common_command.index('-ggdb'), common_command.index('-g1')) + else: + self.assertNotIn('-g1', common_command) + special = next(c['command'] for c in commands if c['file'].endswith('special.cpp')) + self.assertNotIn('cmake_pch', special) + run('cmake', '--build', build, '--parallel', '2', cwd=root, env=env) + requests = [json.loads(line) for line in calls.read_text().splitlines()] + self.assertTrue(any('plain.c' in ' '.join(req) for req in requests)) + self.assertFalse(any('cmake_pch' in ' '.join(req) or + 'common.cpp' in ' '.join(req) or + 'external.cpp' in ' '.join(req) for req in requests)) + run('cmake', '--build', build, '--target', 'unittests', '--parallel', '2', + cwd=root, env=env) + run(build / 'Source/UnitTests/tests', cwd=root, env=env) + for _ in range(2): + run('cmake', '--build', build, '--parallel', '2', cwd=root, env=env) + run('ctest', '--test-dir', build, '--output-on-failure', '--no-tests=error', + cwd=root, env=env) + self.assertEqual((build / 'executed.txt').read_text().splitlines(), + ['executed', 'executed']) + # Reconfiguring must not resurrect a cached launcher in probes. + calls.unlink() + run(*configure, cwd=root, env=env) + self.assertFalse(calls.exists()) + + def test_late_objective_c_languages_preserve_profiles_and_sanitizers(self): + # Compile real Objective-C languages with host Clang; select the Apple + # policy without pretending this fixture validates macOS frameworks. + cc, cxx = shutil.which('clang'), shutil.which('clang++') + self.assertTrue(cc and cxx, 'Clang is required for Objective-C CI regressions') + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + write(root, 'part.m', 'int objc_value(void) { return 20; }\n') + write(root, 'part.mm', '''\ + #include + int objcxx_value() { std::array a{11, 11}; return a[0] + a[1]; } + ''') + write(root, 'main.cpp', '''\ + extern "C" int objc_value(void); + int objcxx_value(); + int main() { return objc_value() + objcxx_value() == 42 ? 0 : 1; } + ''') + write(root, 'CMakeLists.txt', '''\ + cmake_minimum_required(VERSION 3.25) + project(dolphin-emu LANGUAGES C CXX) + set(APPLE TRUE) + enable_language(OBJC OBJCXX) + add_compile_options(-ggdb) + add_library(parts STATIC part.m part.mm) + add_executable(check main.cpp) + target_link_libraries(check PRIVATE parts) + enable_testing() + add_test(NAME language_execution COMMAND check) + ''') + for event, config in [('pull_request', 'Release'), + ('workflow_dispatch', 'Release'), + ('pull_request', 'Debug')]: + with self.subTest(event=event, config=config): + env = dict(os.environ, GITHUB_ACTIONS='true', GITHUB_EVENT_NAME=event) + for variable in ('S2K_CI_COMPILER_CACHE', 'CMAKE_C_COMPILER_LAUNCHER', + 'CMAKE_CXX_COMPILER_LAUNCHER'): + env.pop(variable, None) + # Reconfigure the same tree to detect sticky CI-only flags + # without repeating unchanged compiler ABI detection. + build = root / 'build' + flags = '-fsanitize=address,undefined -fno-omit-frame-pointer' + configure = ['cmake', '-S', root, '-B', build, '-G', 'Ninja', + f'-DCMAKE_BUILD_TYPE={config}', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON', + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={PRESET}', + f'-DCMAKE_C_COMPILER={cc}', f'-DCMAKE_CXX_COMPILER={cxx}', + f'-DCMAKE_OBJC_COMPILER={cc}', f'-DCMAKE_OBJCXX_COMPILER={cxx}'] + configure += [f'-DCMAKE_{language}_FLAGS={flags}' + for language in ('C', 'CXX', 'OBJC', 'OBJCXX')] + run(*configure, cwd=root, env=env) + commands = json.loads((build / 'compile_commands.json').read_text()) + self.assertEqual(len(commands), 3) + for command in commands: + options = command['command'].split() + self.assertIn('-fsanitize=address,undefined', options) + self.assertIn('-fno-omit-frame-pointer', options) + if config == 'Release': + optimization = [arg for arg in options + if arg in ['-O0', '-O1', '-O2', '-O3', '-Os', '-Oz']] + self.assertEqual(optimization[-1], + '-O0' if event == 'pull_request' else '-O3') + self.assertIn('-DNDEBUG', options) + else: + self.assertNotIn('-O0', options) + self.assertNotIn('-DNDEBUG', options) + if config == 'Release' and event == 'pull_request': + self.assertLess(options.index('-ggdb'), options.index('-g1')) + else: + self.assertNotIn('-g1', options) + run('cmake', '--build', build, '--parallel', '2', cwd=root, env=env) + run('ctest', '--test-dir', build, '--output-on-failure', '--no-tests=error', + cwd=root, env=env) + + def test_manual_msvc_pch_is_not_a_cmake_pch_property(self): + # This exercises target wiring on POSIX. The real Windows job remains + # responsible for compiling/linking the upstream /Yc and /Yu commands. + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + write(root, 'part.cpp', 'int part;\n') + write(root, 'Source/PCH/pch.h', '#pragma once\n') + write(root, 'CMakeLists.txt', '''\ + cmake_minimum_required(VERSION 3.25) + project(dolphin-emu LANGUAGES CXX) + set(MSVC TRUE) + # Earlier /Od values used to be retained by option deduplication. + add_compile_options(/Od /Ob0) + add_library(build_pch STATIC part.cpp) + add_library(use_pch INTERFACE) + add_dependencies(use_pch build_pch) + add_custom_command(OUTPUT AchievementApprovedHash.h + COMMAND "${CMAKE_COMMAND}" -E touch AchievementApprovedHash.h) + add_library(core STATIC part.cpp AchievementApprovedHash.h) + target_link_libraries(core PRIVATE use_pch) + add_library(ordinary STATIC part.cpp) + set(ENABLE_QT ON) + add_executable(dolphin-emu part.cpp) + # This fixture checks target wiring, not a simulated Qt build. + # The native Windows job compiles the actual Qt headers and moc. + add_dependencies(dolphin-emu build_pch) + set_property(TARGET dolphin-emu PROPERTY AUTOGEN_TARGET_DEPENDS ordinary) + file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/qt.txt" CONTENT + "$|$|$") + foreach(t build_pch core ordinary dolphin-emu) + target_compile_options(${t} PRIVATE "$<$:/O2>" "$<$:/Ob2>") + set_target_properties(${t} PROPERTIES LINKER_LANGUAGE CXX + C_COMPILER_LAUNCHER bad-inherited CXX_COMPILER_LAUNCHER bad-inherited) + file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/${t}.txt" CONTENT + "$|$|$") + endforeach() + ''') + env = dict(os.environ, GITHUB_ACTIONS='true', GITHUB_EVENT_NAME='pull_request', + S2K_CI_COMPILER_CACHE='cmake;-E;env') + run('cmake', '-S', root, '-B', root / 'build', '-G', 'Ninja', + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={PRESET}', + '-DCMAKE_BUILD_TYPE=Release', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON', + '-DCMAKE_C_COMPILER_LAUNCHER=cmake;-E;env', + '-DCMAKE_CXX_COMPILER_LAUNCHER=cmake;-E;env', cwd=root, env=env) + for target in ['core', 'build_pch']: + self.assertEqual((root / 'build' / f'{target}.txt').read_text(), '||ON') + self.assertEqual((root / 'build/ordinary.txt').read_text(), + 'cmake;-E;env|cmake;-E;env|ON') + self.assertEqual((root / 'build/dolphin-emu.txt').read_text(), '||') + qt = (root / 'build/qt.txt').read_text() + self.assertIn('Source/PCH/pch.h', qt) + self.assertIn('QtWidgets', qt) + self.assertIn('|OFF|ordinary;build_pch;', qt) + self.assertTrue(qt.endswith('/build/AchievementApprovedHash.h')) + commands = json.loads((root / 'build/compile_commands.json').read_text()) + for command in commands: + options = command['command'].split() + self.assertEqual([o for o in options if o in ('/Od', '/O1', '/O2')][-1], '/Od') + self.assertEqual([o for o in options if o in ('/Ob0', '/Ob1', '/Ob2')][-1], '/Ob0') + # Explicit release artifacts must retain the later upstream optimizer. + env['GITHUB_EVENT_NAME'] = 'workflow_dispatch' + run('cmake', '-S', root, '-B', root / 'manual', '-G', 'Ninja', + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={PRESET}', + '-DCMAKE_BUILD_TYPE=Release', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON', cwd=root, env=env) + self.assertTrue((root / 'manual/qt.txt').read_text().endswith('|ON|ordinary'), (root / 'manual/qt.txt').read_text()) + commands = json.loads((root / 'manual/compile_commands.json').read_text()) + for command in commands: + options = command['command'].split() + self.assertEqual([o for o in options if o in ('/Od', '/O1', '/O2')][-1], '/O2') + self.assertEqual([o for o in options if o in ('/Ob0', '/Ob1', '/Ob2')][-1], '/Ob2') + + + +class NativePinVerificationTests(unittest.TestCase): + def test_local_recursive_gitlinks_reject_missing_and_stale_pins(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + env = dict(os.environ, GIT_ALLOW_PROTOCOL='file', + GIT_AUTHOR_NAME='Fixture', GIT_AUTHOR_EMAIL='fixture@example.invalid', + GIT_COMMITTER_NAME='Fixture', GIT_COMMITTER_EMAIL='fixture@example.invalid') + + def git(where, *args): + return run('git', '-C', where, *args, cwd=root, env=env).stdout.strip() + + leaf, library, parent = [root / name for name in ('leaf', 'library', 'parent')] + for repo in (leaf, library, parent): + repo.mkdir() + git(repo, 'init', '-q') + write(repo, 'version', 'one\n') + git(repo, 'add', '.') + git(repo, 'commit', '-qm', 'one') + old_leaf = git(leaf, 'rev-parse', 'HEAD') + write(leaf, 'version', 'two\n') + git(leaf, 'commit', '-qam', 'two') + new_leaf = git(leaf, 'rev-parse', 'HEAD') + git(library, 'submodule', 'add', '-q', str(leaf), 'nested') + git(library / 'nested', 'checkout', '-q', old_leaf) + git(library, 'commit', '-qam', 'pin nested') + git(parent, 'submodule', 'add', '-q', str(library), 'Externals/UnknownDependency') + git(parent, 'commit', '-qam', 'pin native') + native = parent / 'Externals/UnknownDependency' + with self.assertRaisesRegex(ValueError, 'recursive gitlink'): + checkout_native.verify_checkout(parent, 'linux') + git(parent, 'submodule', 'update', '--init', '--recursive') + checkout_native.verify_checkout(parent, 'linux') + git(native / 'nested', 'checkout', '-q', new_leaf) + with self.assertRaisesRegex(ValueError, 'recursive gitlink'): + checkout_native.verify_checkout(parent, 'linux') + git(native / 'nested', 'checkout', '-q', old_leaf) + git(native, 'checkout', '-q', 'HEAD~1') + with self.assertRaisesRegex(ValueError, 'expected .* got'): + checkout_native.verify_checkout(parent, 'linux') + git(parent, 'submodule', 'deinit', '-f', '--all') + with self.assertRaisesRegex(ValueError, 'Uninitialized native dependency'): + checkout_native.verify_checkout(parent, 'linux') + + +if __name__ == '__main__': + unittest.main() diff --git a/Tools/test_native_ci_setup.py b/Tools/test_native_ci_setup.py new file mode 100644 index 000000000000..300703eda144 --- /dev/null +++ b/Tools/test_native_ci_setup.py @@ -0,0 +1,131 @@ +# Copyright 2026 Dolphin Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Setup regressions loaded by the existing Switch2Kit CTest CI test group.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from checkout_native import checkout, git, native_modules + + +class NativeCheckoutTests(unittest.TestCase): + def test_only_foreign_binary_and_android_modules_are_excluded(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + paths = ('Externals/Qt', 'Externals/FFmpeg-bin', 'Externals/libadrenotools', + 'Externals/Switch2Kit', 'Externals/SDL/SDL', 'new-dependency') + (root / '.gitmodules').write_text(''.join( + f'[submodule "{path}"]\npath = {path}\nurl = example\n' for path in paths)) + for platform in ('linux', 'darwin', 'win32'): + selected = {path for _, path, active in native_modules(root, platform) if active} + self.assertEqual(selected, set(paths) - {'Externals/libadrenotools'} - + (set() if platform == 'win32' else {'Externals/Qt', 'Externals/FFmpeg-bin'})) + with self.assertRaises(ValueError): + native_modules(root, 'unsupported') + (root / '.gitmodules').write_text('[submodule "bad"]\npath = ../escape\n') + with self.assertRaises(ValueError): + native_modules(root, 'linux') + + def test_parallel_updates_use_committed_pins_not_remote_heads(self): + # Real local Git repositories: no network or timing-dependent sleeps. + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + 'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'protocol.file.allow', + 'GIT_CONFIG_VALUE_0': 'always'}): + root = Path(directory) / 'parent' + origin = Path(directory) / 'dependency' + for repo in (root, origin): + repo.mkdir() + git(repo, 'init', '-q') + git(repo, 'config', 'user.name', 'Test') + git(repo, 'config', 'user.email', 'test@example.invalid') + (origin / 'value').write_text('pinned\n') + git(origin, 'add', '.') + git(origin, 'commit', '-qm', 'pin') + pin = git(origin, 'rev-parse', 'HEAD').strip() + (origin / 'value').write_text('not the pinned version\n') + git(origin, 'commit', '-qam', 'remote head') + paths = ('Externals/first', 'Externals/second with space') + (root / '.gitmodules').write_text(''.join( + f'[submodule "{path}"]\npath = {path}\nurl = {origin.as_uri()}\n' for path in paths)) + git(root, 'add', '.gitmodules') + for path in paths: + git(root, 'update-index', '--add', '--cacheinfo', f'160000,{pin},{path}') + git(root, 'commit', '-qm', 'parent') + checkout(root, 'linux') + for path in paths: + self.assertEqual(git(root / path, 'rev-parse', 'HEAD').strip(), pin) + self.assertEqual((root / path / 'value').read_text(), 'pinned\n') + # No matching committed gitlink must be a failure, not an empty pass. + (root / '.gitmodules').write_text('[submodule "missing"]\npath = missing\nurl = bad\n') + with self.assertRaises((ValueError, subprocess.CalledProcessError)): + checkout(root, 'linux') + + +class NativeCacheSetupTests(unittest.TestCase): + def test_child_project_cannot_undo_msvc_smoke_options(self): + repository = Path(__file__).resolve().parents[1] + for event in ('pull_request', 'workflow_dispatch'): + with self.subTest(event=event), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'child').mkdir() + (root / 'CMakeLists.txt').write_text('cmake_minimum_required(VERSION 3.25)\n' + 'set(MSVC TRUE)\nset(CMAKE_CXX_COMPILER_ID MSVC)\n' + 'project(dolphin-emu LANGUAGES NONE)\nadd_subdirectory(child)\n') + (root / 'child/CMakeLists.txt').write_text( + 'project(dependency LANGUAGES NONE)\n' + f'include("{repository}/CMake/FlagsOverride.cmake")\n' + 'get_directory_property(options COMPILE_OPTIONS)\n' + 'file(WRITE "${CMAKE_BINARY_DIR}/child.txt" "${CMAKE_CXX_FLAGS_RELEASE}\\n${options}")\n') + result = subprocess.run(['cmake', '-S', str(root), '-B', str(root / 'build'), + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={repository}/Tools/ci-native.cmake'], + env=dict(os.environ, GITHUB_ACTIONS='true', GITHUB_EVENT_NAME=event), + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + flags, options = (root / 'build/child.txt').read_text().split('\n', 1) + self.assertEqual(flags, '/O2 /DNDEBUG /Z7') + if event == 'pull_request': + self.assertIn(':/Od>', options) + self.assertIn(':/Ob0>', options) + else: + self.assertEqual(options, '') + + def test_cache_is_not_used_by_probes_or_pch_consumers(self): + hook = Path(__file__).resolve().parent / 'ci-native.cmake' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'Source/PCH').mkdir(parents=True) + (root / 'Source/PCH/pch.h').write_text('#include \n') + (root / 'value.cpp').write_text('int value() { return 0; }\n') + (root / 'plain.c').write_text('int plain(void) { return 0; }\n') + (root / 'CMakeLists.txt').write_text(''' +cmake_minimum_required(VERSION 3.25) +project(dolphin-emu LANGUAGES C CXX) +if(CMAKE_C_COMPILER_LAUNCHER OR CMAKE_CXX_COMPILER_LAUNCHER) + message(FATAL_ERROR "Configure probes must not use the cache wrapper") +endif() +include(CheckCSourceCompiles) +check_c_source_compiles("int main(void) { return 0; }" HAVE_WORKING_C) +if(NOT HAVE_WORKING_C) + message(FATAL_ERROR "The real compiler probe must still succeed") +endif() +add_library(common OBJECT value.cpp) +add_library(ordinary OBJECT plain.c) +file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/launchers.json" CONTENT + "[\\"$\\",\\"$\\"]") +''') + env = dict(os.environ, GITHUB_ACTIONS='true', GITHUB_EVENT_NAME='pull_request', + CC='clang', CXX='clang++') + command = ['cmake', '-S', str(root), '-B', str(root / 'build'), '-G', 'Ninja', + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={hook}', + '-DCMAKE_C_COMPILER_LAUNCHER=cmake;-E;env', + '-DCMAKE_CXX_COMPILER_LAUNCHER=cmake;-E;env'] + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(json.loads((root / 'build/launchers.json').read_text()), ['', 'cmake;-E;env']) + result = subprocess.run(['cmake', '--build', str(root / 'build'), '--parallel', '2'], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/Tools/test_switch2kit_autoconnect.py b/Tools/test_switch2kit_autoconnect.py old mode 100755 new mode 100644 index 42c147c69b98..9a9b48792b62 --- a/Tools/test_switch2kit_autoconnect.py +++ b/Tools/test_switch2kit_autoconnect.py @@ -2,7 +2,10 @@ # Copyright 2026 Dolphin Emulator Project # SPDX-License-Identifier: GPL-2.0-or-later """Static UI/startup guards; execute test_switch2kit_host.py for host behavior.""" +import json from pathlib import Path +import subprocess +import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] @@ -61,11 +64,59 @@ def test_polling_and_initialization_never_start_bluetooth(self): self.assertIn("s_auto_start_pending = false;", stop) self.assertIn("s_started = false;", stop) + def test_normal_test_build_does_not_require_the_backend(self): + # Evaluate the real parent registration with empty upstream targets; + # this checks CTest membership, not an emulator build or native behavior. + def catalogue(source, build, *flags): + subprocess.run(['cmake', '-S', str(source), '-B', str(build), *flags], + check=True, capture_output=True, timeout=15) + result = subprocess.run(['ctest', '--test-dir', str(build), '--show-only=json-v1'], + check=True, capture_output=True, text=True, timeout=5) + return {test['name']: test.get('command') + for test in json.loads(result.stdout)['tests']} + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + expected = catalogue(ROOT / 'Source/UnitTests/Switch2Kit', root / 'standalone') + self.assertTrue(expected, 'The focused test catalogue must not be empty') + for name in ('Common', 'Core', 'VideoCommon', 'Switch2Kit'): + (root / name).mkdir() + (root / name / 'CMakeLists.txt').write_text('') + (root / 'Switch2Kit/CMakeLists.txt').write_text( + f'include("{(ROOT / "Source/UnitTests/Switch2Kit/CMakeLists.txt").as_posix()}")\n') + for name in ('UnitTestsMain.cpp', 'StubHost.cpp'): + (root / name).write_text('// Configure-only registration fixture.\n') + (root / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.25)\n' + 'project(TestRegistration LANGUAGES CXX)\n' + 'set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Binaries")\n' + 'foreach(dependency fmt::fmt gtest::gtest core uicommon)\n' + ' add_library(${dependency} INTERFACE IMPORTED)\n' + 'endforeach()\n' + f'include("{(ROOT / "Source/UnitTests/CMakeLists.txt").as_posix()}")\n') + for enabled in ('OFF', 'ON'): + with self.subTest(ENABLE_SWITCH2KIT=enabled): + registered = catalogue(root, root / 'normal', f'-DENABLE_SWITCH2KIT={enabled}') + self.assertIn('tests', registered, 'Keep the upstream test registration') + self.assertEqual(expected, {name: command for name, command in registered.items() + if name.startswith('Switch2Kit.')}) + def test_ci_retains_regressions(self): - workflow = self.read(".github/workflows/native-switch2kit.yml") - for script in ("test_switch2kit.py", "test_switch2kit_host.py --sanitize", - "test_switch2kit_mapping.py --sanitize", "test_switch2kit_autoconnect.py"): - self.assertIn(script, workflow) + # Check CTest's actual commands, not copies of command names in comments. + with tempfile.TemporaryDirectory() as build: + subprocess.run(['cmake', '-S', str(ROOT / 'Source/UnitTests/Switch2Kit'), + '-B', build], check=True, capture_output=True, timeout=15) + result = subprocess.run(['ctest', '--test-dir', build, '--show-only=json-v1'], + check=True, capture_output=True, text=True, timeout=5) + commands = {tuple(test['command'][1:]) for test in json.loads(result.stdout)['tests']} + for script, flags in (('test_switch2kit.py', ()), + ('test_switch2kit_host.py', ('--sanitize',)), + ('test_switch2kit_mapping.py', ('--sanitize',)), + ('test_switch2kit_autoconnect.py', ())): + self.assertIn((str(ROOT / 'Tools' / script), *flags), commands) + workflow = self.read('.github/workflows/native-switch2kit.yml') + self.assertIn('ctest --test-dir build-switch2kit-tests --output-on-failure --no-tests=error', + workflow) if __name__ == "__main__": diff --git a/Tools/test_switch2kit_ci.py b/Tools/test_switch2kit_ci.py new file mode 100644 index 000000000000..65e22546ece2 --- /dev/null +++ b/Tools/test_switch2kit_ci.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dolphin Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Small regressions for change selection; no bespoke workflow parser or runner.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from switch2kit_ci import TARGETS, changed_paths, select_checks +from test_native_ci_setup import NativeCacheSetupTests, NativeCheckoutTests + +NATIVE = {'macos', 'linux', 'windows'} + + +class ChangeSelectionTests(unittest.TestCase): + def test_documentation_does_not_build_applications(self): + self.assertEqual(select_checks(['Readme.md', 'Docs/Switch2Kit.md', 'AGENTS.md']), set()) + + def test_every_shared_production_input_rebuilds_native_applications(self): + for path in ('Source/Core/DolphinQt/Config/ControllersPane.cpp', + 'Source/Core/Common/Config/Config.h', 'CMakeLists.txt', + 'CMake/DolphinSwitch2Kit.cmake', 'Externals/fmt/fmt', + 'Data/Sys/Profiles/GCPad/Switch2Kit GameCube.ini', 'new-build-input'): + with self.subTest(path=path): + self.assertEqual(select_checks([path]), NATIVE) + + def test_sdk_and_sdl_pins_restore_real_sdk_and_all_native_checks(self): + for path in ('Externals/Switch2Kit', 'Externals/SDL/SDL', '.gitmodules', + 'Externals/Switch2Kit/Package.swift'): + self.assertEqual(select_checks([path]), TARGETS) + + def test_platform_specific_tools_do_not_rebuild_unaffected_platforms(self): + for target in NATIVE: + self.assertEqual(select_checks([f'.github/workflows/switch2kit-{target}.yml']), {target}) + self.assertEqual(select_checks(['Tools/build-switch2kit-linux.sh']), {'linux'}) + self.assertEqual(select_checks(['Tools/test_switch2kit_bundle.py']), {'macos'}) + self.assertEqual(select_checks(['Tools/test_switch2kit_windows_launch.ps1']), {'windows'}) + + def test_orchestration_changes_validate_the_entire_graph(self): + for path in ('Tools/switch2kit_ci.py', 'Tools/test_switch2kit_ci.py', + '.github/workflows/native-switch2kit.yml'): + self.assertEqual(select_checks([path]), TARGETS) + + def test_fast_fixture_changes_stay_in_the_always_run_ctest_suite(self): + self.assertEqual(select_checks(['Tools/test_switch2kit_host.py', + 'Tools/switch2kit/HostTest.cpp', + 'Source/UnitTests/Switch2Kit/CMakeLists.txt']), set()) + self.assertEqual(select_checks(['Source/UnitTests/CMakeLists.txt']), {'linux'}) + + def test_multiple_changes_union_the_required_checks(self): + self.assertEqual(select_checks(['Docs/Switch2Kit.md', + 'Tools/build-switch2kit-linux.sh', + 'Tools/test_switch2kit_bundle.py']), {'linux', 'macos'}) + + def test_git_failure_cannot_be_reported_as_no_changes(self): + with patch('switch2kit_ci.subprocess.run', side_effect=subprocess.CalledProcessError(1, 'git')): + with self.assertRaises(subprocess.CalledProcessError): + changed_paths('missing-base') + + def test_deleted_renamed_and_whitespace_paths_are_not_lost(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def git(*args): + return subprocess.run(['git', '-C', str(root), *args], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + git('init', '-q') + git('config', 'user.name', 'Test') + git('config', 'user.email', 'test@example.invalid') + source = root / 'Source/Core/file with space.cpp' + source.parent.mkdir(parents=True) + source.write_text('int value;\n') + git('add', '.') + git('commit', '-qm', 'base') + (root / 'Docs').mkdir() + source.rename(root / 'Docs/moved.cpp') + git('add', '-A') + git('commit', '-qm', 'rename') + previous = os.getcwd() + try: + os.chdir(root) + paths = changed_paths('HEAD~1') + finally: + os.chdir(previous) + self.assertIn('Source/Core/file with space.cpp', paths) + self.assertIn('Docs/moved.cpp', paths) + self.assertEqual(select_checks(paths), NATIVE) + +class NativeBuildSetupTests(unittest.TestCase): + root = Path(__file__).resolve().parents[1] + + def configure(self, root, event='pull_request', **extra_env): + env = dict(os.environ, GITHUB_ACTIONS='true', GITHUB_EVENT_NAME=event, **extra_env) + # Fixtures exercise CMake, not the surrounding native job's cache server. + for name in ('CMAKE_C_COMPILER_LAUNCHER', 'CMAKE_CXX_COMPILER_LAUNCHER', + 'S2K_CI_COMPILER_CACHE'): + env.pop(name, None) + result = subprocess.run(['cmake', '-S', str(root), '-B', str(root / 'build'), + '-G', 'Ninja', '-DCMAKE_BUILD_TYPE=Release', + f'-DCMAKE_PROJECT_dolphin-emu_INCLUDE={self.root}/Tools/ci-native.cmake'], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_release_flags_after_upstream_msvc_override(self): + # This is a CMake scope regression, not a simulated Windows compiler. + # Load the real upstream flag file and then use the actual project hook. + for event, expected in [('pull_request', '/Od /Ob0 /DNDEBUG /Z7'), + ('workflow_dispatch', '/O2 /DNDEBUG /Z7')]: + with self.subTest(event=event), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'CMakeLists.txt').write_text(f''' +cmake_minimum_required(VERSION 3.25) +set(MSVC TRUE) +set(CMAKE_CXX_COMPILER_ID MSVC) +set(CMAKE_CXX_FLAGS_RELEASE "/Od /DNDEBUG" CACHE STRING "") +include("{self.root}/CMake/FlagsOverride.cmake") +project(dolphin-emu LANGUAGES NONE) +file(WRITE "${{CMAKE_BINARY_DIR}}/flags.txt" "${{CMAKE_CXX_FLAGS_RELEASE}}") +''') + self.configure(root, event) + self.assertEqual((root / 'build/flags.txt').read_text(), expected) + + def test_pch_keeps_per_target_flags_and_linux_optimization(self): + import json + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'Source/PCH').mkdir(parents=True) + (root / 'Source/PCH/pch.h').write_text(''' +#include +#include +#ifndef FIXTURE_VALUE +#error Per-target definitions must reach the PCH. +#endif +#ifndef NDEBUG +#error Release definitions must be retained. +#endif +''') + (root / 'QtWidgets').write_text('#pragma once\n') + (root / 'value.cpp').write_text('int value() { return FIXTURE_VALUE; }\n') + (root / 'special.cpp').write_text('int special() { return 2; }\n') + (root / 'plain.c').write_text('int c_value(void) { return 1; }\n') + (root / 'component').mkdir() + (root / 'component/CMakeLists.txt').write_text(''' +add_library(common ../value.cpp ../plain.c ../special.cpp) +set_source_files_properties(../special.cpp PROPERTIES COMPILE_OPTIONS -std=c++20) +target_compile_definitions(common PRIVATE FIXTURE_VALUE=7) +''') + (root / 'main.cpp').write_text(''' +#include +extern int value(); +int main() { std::vector values{value()}; return values[0] == 7 ? 0 : 1; } +''') + (root / 'CMakeLists.txt').write_text(''' +cmake_minimum_required(VERSION 3.25) +project(dolphin-emu LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(ENABLE_QT ON) +add_subdirectory(component) +add_executable(dolphin-emu main.cpp) +target_compile_definitions(dolphin-emu PRIVATE FIXTURE_VALUE=9) +target_include_directories(dolphin-emu PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(dolphin-emu PRIVATE common) +''') + self.configure(root, CC='clang', CXX='clang++') + result = subprocess.run(['cmake', '--build', str(root / 'build'), '--parallel', '2'], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + subprocess.run([str(root / 'build/dolphin-emu')], check=True, timeout=2) + commands = json.loads((root / 'build/compile_commands.json').read_text()) + plain = next(entry['command'] for entry in commands if entry['file'].endswith('plain.c')) + self.assertNotIn('cmake_pch.hxx', plain) + special = next(entry['command'] for entry in commands if entry['file'].endswith('special.cpp')) + self.assertNotIn('cmake_pch.hxx', special) + self.assertIn('-std=c++20', special) + cpp = next(entry['command'] for entry in commands if entry['file'].endswith('value.cpp')) + self.assertIn('cmake_pch.hxx', cpp) + self.assertIn('-O0' if sys.platform == 'darwin' else '-O1', cpp) + self.assertIn('-DNDEBUG', cpp) + + def test_ci_build_acceleration_changes_select_all_native_platforms(self): + self.assertEqual(select_checks(['Tools/ci-native.cmake']), NATIVE) + + +if __name__ == '__main__': + unittest.main(verbosity=2)