diff --git a/.github/actions/setup-godot-cpp/action.yml b/.github/actions/setup-godot-cpp/action.yml index 33f0927fb..f5f93f3cc 100644 --- a/.github/actions/setup-godot-cpp/action.yml +++ b/.github/actions/setup-godot-cpp/action.yml @@ -1,71 +1,110 @@ name: Setup godot-cpp -description: Setup build dependencies for godot-cpp. +description: Setup build dependencies for godot-cpp (SCons or CMake). inputs: platform: required: true description: Target platform. - em-version: - default: 4.0.11 - description: Emscripten version. - windows-compiler: - required: true - description: The compiler toolchain to use on Windows ('mingw' or 'msvc'). type: choice options: - - mingw + - linux + - windows + - macos + - android + - web + + compiler: + description: Compiler toolchain (auto selects sensible default per platform). + required: false + default: auto + type: choice + options: + - auto + - gcc + - clang - msvc - default: mingw + - mingw + mingw-version: default: 12.2.0 - description: MinGW version. + description: MinGW version (used only for windows + mingw). + ndk-version: default: r28b description: Android NDK version. + + em-version: + default: 3.1.74 + description: Emscripten version (recommended for Godot 4.x web). + buildtool: + description: Build tool to prepare (scons or cmake). default: scons - description: scons or cmake + type: choice + options: + - scons + - cmake + scons-version: - default: 4.4.0 + default: 4.8.0 description: SCons version. - runs: using: composite steps: - - name: Setup Python (for SCons) + - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.x + python-version: 3.12 - - name: Setup Android dependencies - if: inputs.platform == 'android' + # === Compiler Toolchain Setup === + + - name: Setup Clang on Linux or Windows + if: ${{ (inputs.platform == 'linux' || inputs.platform == 'windows') && inputs.compiler == 'clang' }} + uses: egor-tensin/setup-clang@v2 + with: + version: 18 + + - name: Setup MinGW (Windows) + if: ${{ inputs.platform == 'windows' && (inputs.compiler == 'mingw' || (inputs.compiler == 'auto' && inputs.compiler != 'msvc')) }} + uses: egor-tensin/setup-mingw@v2 + with: + version: ${{ inputs.mingw-version }} + + # MSVC requires no extra setup on windows-latest runners (Visual Studio is preinstalled) + + - name: Setup Android NDK + if: ${{ inputs.platform == 'android' }} uses: nttld/setup-ndk@v1 with: ndk-version: ${{ inputs.ndk-version }} link-to-sdk: true - - name: Setup Web dependencies - if: inputs.platform == 'web' + - name: Setup Emscripten (Web) + if: ${{ inputs.platform == 'web' }} uses: mymindstorm/setup-emsdk@v14 with: version: ${{ inputs.em-version }} no-cache: true - - name: Setup MinGW for Windows/MinGW build - if: inputs.platform == 'windows' && inputs.windows-compiler == 'mingw' - uses: egor-tensin/setup-mingw@v2 - with: - version: ${{ inputs.mingw-version }} + # macOS uses AppleClang by default β€” no extra setup needed on macos-latest + + # === Build Tool Setup === - name: Setup SCons if: ${{ inputs.buildtool == 'scons' }} shell: bash run: | - python -c "import sys; print(sys.version)" python -m pip install scons==${{ inputs.scons-version }} scons --version - - name: Install Ninja + - name: Install Ninja (for CMake) if: ${{ inputs.buildtool == 'cmake' }} - uses: ashutoshvarma/setup-ninja@master + uses: ashutoshvarma/setup-ninja@v1.1 + + - name: Debug chosen configuration + shell: bash + run: | + echo "Platform : ${{ inputs.platform }}" + echo "Compiler : ${{ inputs.compiler }}" + echo "Buildtool: ${{ inputs.buildtool }}" diff --git a/.github/workflows/ci-cmake.yml b/.github/workflows/ci-cmake.yml index 6b64a1ddd..7c47fee56 100644 --- a/.github/workflows/ci-cmake.yml +++ b/.github/workflows/ci-cmake.yml @@ -3,7 +3,7 @@ on: workflow_call: env: - # Only used for the cache key. Increment version to force clean build. + # Only used for the cache key. Increment version to force a clean build. GODOT_BASE_BRANCH: master # Used to select the version of Godot to run the tests with. GODOT_TEST_VERSION: master @@ -24,62 +24,170 @@ jobs: config-flags: -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + -DCMAKE_C_LINKER_LAUNCHER=sccache + -DCMAKE_CXX_LINKER_LAUNCHER=sccache -DGODOTCPP_ENABLE_TESTING=ON -DGODOTCPP_BUILD_PROFILE="test/build_profile.json" SCCACHE_GHA_ENABLED: "true" + DEBUG_BUILD_START: "" + RELEASE_BUILD_START: "" strategy: fail-fast: false matrix: include: + # Linux-gcc - name: 🐧 Linux (GCC, Makefiles) os: ubuntu-22.04 platform: linux config-flags: -DCMAKE_BUILD_TYPE=Release - artifact-name: godot-cpp-linux-glibc2.27-x86_64-release.cmake + artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a run-tests: true + godot-test-arch: "linux.x86_64" + godot-master-workflow: linux_builds.yml + godot-master-artifact: linux-editor-mono + - name: 🐧 Linux (GCC, Makefiles) for Godot 4.6 + os: ubuntu-22.04 + platform: linux + config-flags: + -DCMAKE_BUILD_TYPE=Release + -DGODOTCPP_API_VERSION=4.6 + artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot46 + artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a + run-tests: true + godot-test-versions: "4.6-stable" + godot-test-arch: "linux.x86_64" + godot-master-workflow: linux_builds.yml + godot-master-artifact: linux-editor-mono + + - name: 🐧 Linux (GCC, Makefiles) for Godot 4.5 + os: ubuntu-22.04 + platform: linux + config-flags: + -DCMAKE_BUILD_TYPE=Release + -DGODOTCPP_API_VERSION=4.5 + artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot45 + artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a + run-tests: true + godot-test-versions: "4.5-stable 4.6-stable" + godot-test-arch: "linux.x86_64" + godot-master-workflow: linux_builds.yml + godot-master-artifact: linux-editor-mono + + - name: 🐧 Linux (GCC, Makefiles) for Godot 4.4 + os: ubuntu-22.04 + platform: linux + config-flags: + -DCMAKE_BUILD_TYPE=Release + -DGODOTCPP_API_VERSION=4.4 + artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot44 + artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a + run-tests: true + godot-test-versions: "4.4-stable 4.5-stable 4.6-stable" + godot-test-arch: "linux.x86_64" + godot-master-workflow: linux_builds.yml + godot-master-artifact: linux-editor-mono + + - name: 🐧 Linux (GCC, Makefiles) for Godot 4.3 + os: ubuntu-22.04 + platform: linux + config-flags: + -DCMAKE_BUILD_TYPE=Release + -DGODOTCPP_API_VERSION=4.3 + artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot43 + artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a + run-tests: true + godot-test-versions: "4.3-stable 4.4-stable 4.5-stable 4.6-stable" + godot-test-arch: "linux.x86_64" + godot-master-workflow: linux_builds.yml + godot-master-artifact: linux-editor-mono + + # Linux-clang + - name: 🐧 Linux (Clang, Makefiles) + os: ubuntu-22.04 + platform: linux + compiler: clang + config-flags: -DCMAKE_BUILD_TYPE=Release + artifact-name: godot-cpp-linux-clang-x86_64-cmake-release + artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a + run-tests: true + godot-test-arch: "linux.x86_64" + godot-master-workflow: linux_builds.yml + godot-master-artifact: linux-editor-mono + + # linux android + - name: πŸ€– Android (arm64, Ninja) + os: ubuntu-22.04 + platform: android + config-flags: + -G Ninja -DCMAKE_BUILD_TYPE=Release + --toolchain ${ANDROID_HOME}/ndk/28.1.13356709/build/cmake/android.toolchain.cmake + -DANDROID_PLATFORM=24 -DANDROID_ABI=arm64-v8a + artifact-name: godot-cpp-android-arm64-cmake-release + artifact-path: cmake-build/bin/libgodot-cpp.android.template_release.arm64.a + + # linux web + - name: 🌐 Web (wasm32, Ninja) + os: ubuntu-22.04 + platform: web + config-flags: + -G Ninja -DCMAKE_BUILD_TYPE=Release + --toolchain ${EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake + artifact-name: godot-cpp-web-wasm32-cmake-release + artifact-path: cmake-build/bin/libgodot-cpp.web.template_release.wasm32.a + # Linux-double + # Linux-dev + # Linux-32bit + + # Windows-msvc - name: 🏁 Windows (x86_64, MSVC) os: windows-2022 platform: windows compiler: msvc build-flags: --config Release - artifact-name: godot-cpp-windows-msvc2019-x86_64-release.cmake + artifact-name: godot-cpp-windows-msvc2022-x86_64-cmake-release artifact-path: cmake-build/bin/libgodot-cpp.windows.template_release.x86_64.lib - run-tests: false + run-tests: true + godot-test-arch: "win64.exe" + godot-master-workflow: windows_builds.yml + godot-master-artifact: windows-editor + # Windows-mingw - name: 🏁 Windows (x86_64, MinGW, Ninja) os: windows-2022 platform: windows compiler: mingw config-flags: -GNinja -DCMAKE_BUILD_TYPE=Release - -DCMAKE_CXX_COMPILER=cc -DCMAKE_CXX_COMPILER=c++ - artifact-name: godot-cpp-linux-mingw-x86_64-release.cmake + -DCMAKE_C_COMPILER=cc -DCMAKE_CXX_COMPILER=c++ + artifact-name: godot-cpp-windows-mingw-x86_64-cmake-release artifact-path: cmake-build/bin/libgodot-cpp.windows.template_release.x86_64.a - run-tests: false + run-tests: true + godot-test-arch: "win64.exe" + godot-master-workflow: windows_builds.yml + godot-master-artifact: windows-editor + # Windows-clang? + # Windows-msys2? + # Windows-cygwin? + # Windows-emscripten? + # Windows-android? + # macos - name: 🍎 macOS (universal, Makefiles) os: macos-latest platform: macos config-flags: -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" - artifact-name: godot-cpp-macos-universal-release.cmake + artifact-name: godot-cpp-macos-universal-cmake-release artifact-path: cmake-build/bin/libgodot-cpp.macos.template_release.universal.a - run-tests: false - - - name: πŸ€– Android (arm64, Ninja) - os: ubuntu-22.04 - platform: android - config-flags: - -G Ninja -DCMAKE_BUILD_TYPE=Release - --toolchain ${ANDROID_HOME}/ndk/28.1.13356709/build/cmake/android.toolchain.cmake - -DANDROID_PLATFORM=24 -DANDROID_ABI=arm64-v8a - artifact-name: godot-cpp-android-arm64-release.cmake - artifact-path: cmake-build/bin/libgodot-cpp.android.template_release.arm64.a - flags: arch=arm64 - run-tests: false - + run-tests: true + godot-test-arch: "macos.universal" + godot-master-workflow: macos_builds.yml + godot-master-artifact: macos-editor + # macos-android? + # macos-web? + # macos-ios - name: 🍏 iOS (arm64, XCode) os: macos-latest platform: ios @@ -88,57 +196,59 @@ jobs: --toolchain cmake/ios.toolchain.cmake -DPLATFORM=OS64 build-flags: --config Release - artifact-name: godot-cpp-ios-arm64-release.cmake + artifact-name: godot-cpp-ios-arm64-cmake-release artifact-path: cmake-build/bin/libgodot-cpp.ios.template_release.arm64.a - flags: arch=arm64 - run-tests: false - - - name: 🌐 Web (wasm32, Ninja) - os: ubuntu-22.04 - platform: web - config-flags: - -G Ninja -DCMAKE_BUILD_TYPE=Release - --toolchain ${EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake - artifact-name: godot-cpp-web-wasm32-release.cmake - artifact-path: cmake-build/bin/libgodot-cpp.web.template_release.wasm32.a - run-tests: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: recursive - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 + with: + disable_annotations: true - name: Setup godot-cpp uses: ./.github/actions/setup-godot-cpp with: platform: ${{ matrix.platform }} - windows-compiler: ${{ matrix.compiler }} + compiler: ${{ matrix.compiler }} buildtool: cmake + - name: Clean committed macOS framework bundles for CMake + if: matrix.platform == 'macos' + run: | + echo "Removing pre-committed framework folders (required for CMake FRAMEWORK TRUE)" + rm -rf test/project/bin/libgdexample.macos.template_debug.framework + rm -rf test/project/bin/libgdexample.macos.template_release.framework + + # Configure and build the debug target with CMake - name: Configure godot-cpp-test with template_debug - run: > + id: configure-debug + run: | + echo "DEBUG_BUILD_START=$(date +%s)" >> $GITHUB_ENV cmake --log-level=VERBOSE -S . -B cmake-build ${{ env.config-flags }} ${{ matrix.config-flags }} - name: Build godot-cpp-test (template_debug) - run: > - cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }} + id: build-debug + run: | + cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }} + echo "DEBUG_BUILD_END=$(date +%s)" >> $GITHUB_ENV + # Configure and build the release target with CMake - name: Configure godot-cpp-test with template_release - run: > - cmake --fresh --log-level=VERBOSE -S . -B cmake-build - -DGODOTCPP_TARGET=template_release ${{ env.config-flags }} ${{ matrix.config-flags }} + id: configure-release + run: | + echo "RELEASE_BUILD_START=$(date +%s)" >> $GITHUB_ENV + cmake --fresh --log-level=VERBOSE -S . -B cmake-build -DGODOTCPP_TARGET=template_release ${{ env.config-flags }} ${{ matrix.config-flags }} - name: Build godot-cpp-test (template_release) - run: > - cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }} - - - name: Run sccache stat for check - shell: bash - run: ${SCCACHE_PATH} --show-stats + id: build-release + run: | + cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }} + echo "RELEASE_BUILD_END=$(date +%s)" >> $GITHUB_ENV - name: Download latest Godot artifacts uses: dsnopek/action-download-artifact@1322f74e2dac9feed2ee76a32d9ae1ca3b4cf4e9 @@ -147,40 +257,86 @@ jobs: repo: godotengine/godot branch: master event: push - workflow: linux_builds.yml + workflow: ${{ matrix.godot-master-workflow }} workflow_conclusion: success - name: linux-editor-mono + name: ${{ matrix.godot-master-artifact }} search_artifacts: true check_artifacts: true ensure_latest: true path: godot-artifacts - - name: Prepare Godot artifacts for testing - if: matrix.run-tests && env.GODOT_TEST_VERSION == 'master' - run: | - chmod +x ./godot-artifacts/godot.linuxbsd.editor.x86_64.mono - echo "GODOT=$(pwd)/godot-artifacts/godot.linuxbsd.editor.x86_64.mono" >> $GITHUB_ENV - - - name: Download requested Godot version for testing - if: matrix.run-tests && env.GODOT_TEST_VERSION != 'master' - run: | - wget "https://github.com/godotengine/godot-builds/releases/download/${GODOT_TEST_VERSION}/Godot_v${GODOT_TEST_VERSION}_linux.x86_64.zip" -O Godot.zip - unzip -a Godot.zip - chmod +x "Godot_v${GODOT_TEST_VERSION}_linux.x86_64" - echo "GODOT=$(pwd)/Godot_v${GODOT_TEST_VERSION}_linux.x86_64" >> $GITHUB_ENV - - name: Run tests if: matrix.run-tests - run: | - $GODOT --headless --version - cd test - # Need to run the editor so .godot is generated... but it crashes! Ignore that :-) - (cd project && (timeout 30 $GODOT --import --headless >/dev/null 2>&1 || true)) - ./run-tests.sh + run: python -X utf8 run-tests.py --verbose --godot-test-versions "${{ matrix.godot-test-versions }}" --godot-test-arch "${{ matrix.godot-test-arch }}" + working-directory: test + env: + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact-name }} path: ${{ matrix.artifact-path }} if-no-files-found: error + + - name: Show build and sccache statistics + if: always() + shell: bash + run: | + { + echo "### ⏱️ Build Times" + echo "" + echo "| Target | Duration |" + echo "|---------------------|----------|" + + debug_duration=$((DEBUG_BUILD_END - DEBUG_BUILD_START)) + release_duration=$((RELEASE_BUILD_END - RELEASE_BUILD_START)) + + echo "| template_debug | ${debug_duration}s |" + echo "| template_release | ${release_duration}s |" + echo "" + + STATS=$("${SCCACHE_PATH}" --show-stats 2>/dev/null || echo "") + if [ -n "$STATS" ]; then + REQUESTS=$(echo "$STATS" | grep -oP 'Compile requests\s+\K\d+' || echo "0") + HITS=$(echo "$STATS" | grep -oP 'Cache hits\s+\K\d+' || echo "0") + MISSES=$(echo "$STATS" | grep -oP 'Cache misses\s+\K\d+' || echo "0") + + if [ "$REQUESTS" -gt 0 ]; then + HIT_RATE=$(awk "BEGIN {printf \"%.2f\", ($HITS / $REQUESTS) * 100}") + else + HIT_RATE="0.00" + fi + + echo "### πŸ’Ύ Cache Summary" + echo "" + echo "**sccache hit rate: ${HIT_RATE}%** (${HITS}/${REQUESTS} requests cached)" + if [ "$MISSES" -eq 0 ]; then + echo "πŸ‘Œ **Perfect cache hit** β€” no cache misses!" + else + echo "**${MISSES} cache miss(es)**" + fi + echo "" + + # Collapsible full stats + echo "
" + echo "πŸ“ˆ Full sccache Statistics" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "$STATS" | awk ' + /^[A-Za-z]/ { + gsub(/^[ \t]+|[ \t]+$/, ""); + gsub(/[ \t]{2,}/, "|"); + print "| " $0 " |"; + } + ' + echo "" + echo "
" + else + echo "**sccache statistics not available**" + fi + } >> "$GITHUB_STEP_SUMMARY" || true + + # Stop sccache server (ignore errors) + "${SCCACHE_PATH}" --stop-server || true diff --git a/.github/workflows/ci-scons.yml b/.github/workflows/ci-scons.yml index b2e6e17fd..cfeeca41c 100644 --- a/.github/workflows/ci-scons.yml +++ b/.github/workflows/ci-scons.yml @@ -133,7 +133,7 @@ jobs: uses: ./.github/actions/setup-godot-cpp with: platform: ${{ matrix.platform }} - windows-compiler: ${{ contains(matrix.flags, 'use_mingw=yes') && 'mingw' || 'msvc' }} + compiler: ${{ contains(matrix.flags, 'use_mingw=yes') && 'mingw' || 'msvc' }} buildtool: scons - name: Generate godot-cpp sources only diff --git a/.github/workflows/runner.yml b/.github/workflows/runner.yml index 62f9327d9..2bf228c77 100644 --- a/.github/workflows/runner.yml +++ b/.github/workflows/runner.yml @@ -31,7 +31,8 @@ jobs: with: files_yaml: | sources: - - '.github/workflows/*.yml' + - '.github/workflows/runner.yml' + - '.github/workflows/static_checks.yml' - '**/*.py' - '**/*.cpp' - '**/*.hpp' @@ -40,9 +41,11 @@ jobs: - 'test/build_profile.json' - 'gdextension/extension_api.json' scons: + - '.github/workflows/ci-scons.yml' - '**/SConstruct' - '**/SCsub' cmake: + - '.github/workflows/ci-cmake.yml' - '**/CMakeLists.txt' - '**/*.cmake' - name: echo sources changed diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 239d28f59..cdd6104e1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -29,6 +29,7 @@ target_link_libraries(${TARGET_NAME} PRIVATE godot-cpp) ### Get useful properties from godot-cpp target get_target_property(GODOTCPP_SUFFIX godot-cpp GODOTCPP_SUFFIX) +get_target_property(GODOTCPP_TARGET godot-cpp GODOTCPP_TARGET) # gersemi: off set_target_properties( @@ -60,10 +61,28 @@ set_target_properties( ) # gersemi: on -# CMAKE_SYSTEM_NAME refers to the target system +if(CMAKE_SYSTEM_NAME STREQUAL "Windows" AND MINGW) + # Make the GDExtension DLL self-contained (no external MinGW runtime DLLs) + target_link_options(${TARGET_NAME} PRIVATE "$,-static-libgcc;-static-libstdc++,>") + + # Also ensure no undefined symbols + target_link_options(${TARGET_NAME} PRIVATE -Wl,--no-undefined) +endif() + if(CMAKE_SYSTEM_NAME STREQUAL Darwin) set_target_properties( ${TARGET_NAME} - PROPERTIES SUFFIX "" OUTPUT_DIR "${OUTPUT_DIR}/libgdexample.macos.${GODOTCPP_TARGET}.framework" + PROPERTIES + OUTPUT_NAME "libgdexample.macos.${GODOTCPP_TARGET}" + FRAMEWORK TRUE + FRAMEWORK_VERSION A + MACOSX_FRAMEWORK_IDENTIFIER + "com.godot.gdexample" # can be anything, doesn't matter much + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + # These help with rpath and universal builds + MACOSX_RPATH TRUE + INSTALL_NAME_DIR "@rpath" + BUILD_WITH_INSTALL_NAME_DIR TRUE ) endif() diff --git a/test/run-tests.py b/test/run-tests.py new file mode 100644 index 000000000..fcd80cc02 --- /dev/null +++ b/test/run-tests.py @@ -0,0 +1,665 @@ +#!/usr/bin/env python3 +""" +run-tests.py - Robust test runner for godot-cpp test project (with temp portable Godot copy) +""" + +import argparse +import builtins +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import urllib.request +import zipfile +from pathlib import Path +from typing import Any, Dict, List, Tuple + +# ---------------------------------------------- +# Configuration +# ---------------------------------------------- + +GODOT_EDITOR = os.environ.get("GODOT_EDITOR", "unspecified-editor-binary") +GODOT_RELEASE = os.environ.get("GODOT_RELEASE", "unspecified-release-binary") +GODOT_DEBUG = os.environ.get("GODOT_DEBUG", "unspecified-debug-binary") + +SCRIPT_DIR = Path(__file__).parent.resolve() +PROJECT_DIR = (SCRIPT_DIR / "project").resolve() + +END_MARKER = "==== TESTS FINISHED ====" +PASSED_MARKER = "******** PASSED ********" +FAILED_MARKER = "******** FAILED ********" + +TIMEOUT_SEC = 180 +IMPORT_TIMEOUT_SEC = 30 + +FILTER_INCLUDE_PATTERNS = [ + re.compile(r"^.*={4}\s*TESTS\s*FINISHED\s*={4}"), # ==== ... ==== + re.compile(r"^.*PASSES:\s*\d+"), # PASSES: + re.compile(r"^.*FAILURES:\s*\d+"), # FAILURES: + re.compile(r"^.*\*+\s*PASSED\s*\*+"), # any number of stars around PASSED + re.compile(r"^.*\*+\s*FAILED\s*\*+"), # same for FAILED (useful for future) +] +FILTER_DISCARD_PATTERNS = [ + re.compile(r".*"), # Discard everything that hasn't already been included. +] + +IS_WINDOWS = sys.platform.startswith("win") + +PORTABLE_EDITOR = "godot-editor.exe" if IS_WINDOWS else "godot-editor" +PORTABLE_RELEASE = "godot-release.exe" if IS_WINDOWS else "godot-release" +PORTABLE_DEBUG = "godot-debug.exe" if IS_WINDOWS else "godot-debug" +PORTABLE_MARKER = "_sc_" + +PHASE_CLEANUP = 10 +PHASE_PRE_IMPORT = 20 +PHASE_UNIT_TESTS = 30 + +verbose: bool = False + +# ---------------------------------------------- +# Downloader +# ---------------------------------------------- + + +def download_godot(version: str, arch: str) -> Path: + """Download and extract a specific Godot version and architecture.""" + # Official builds are usually at: + # https://github.com/godotengine/godot-builds/releases/download//Godot_v_.zip + url = f"https://github.com/godotengine/godot-builds/releases/download/{version}/Godot_v{version}_{arch}.zip" + + download_dir = (SCRIPT_DIR / "godot-builds").resolve() + download_dir.mkdir(exist_ok=True) + + zip_path = download_dir / f"Godot_v{version}_{arch}.zip" + + if not zip_path.exists(): + print(f"β†’ Downloading Godot {version} ({arch})...") + try: + urllib.request.urlretrieve(url, zip_path) + print(" [ DONE ]") + except Exception as e: + print(f" [ FAILED ] - {e}") + return Path() + + # Unzip + print(f"β†’ Extracting Godot {version}...") + try: + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(download_dir) + print(" [ DONE ]") + except Exception as e: + print(f" [ FAILED ] - {e}") + return Path() + + # Find the binary + binary_path = find_godot_binary(download_dir, version, arch) + + if binary_path and binary_path.exists(): + if not IS_WINDOWS and not binary_path.is_dir(): + binary_path.chmod(binary_path.stat().st_mode | 0o111) + return binary_path.resolve() + + print(f" [ FAILED ] - Could not find binary for Godot {version} in {download_dir}") + return Path() + + +def find_godot_binary(download_dir: Path, version: str, arch: str) -> Path: + """Search for the Godot binary in the download directory based on common patterns.""" + binary_name = f"Godot_v{version}_{arch}" + + # 1. Direct match + p = download_dir / binary_name + if p.is_file(): + return p + + # 2. Windows executable + p = download_dir / (binary_name + ".exe") + if p.is_file(): + return p + + # 3. macOS App bundle + p = download_dir / "Godot.app" + if p.is_dir(): + binary_p = p / "Contents" / "MacOS" / "Godot" + if binary_p.is_file(): + return binary_p + + # 4. Fallback: Search for anything starting with Godot_v{version} + for f in download_dir.iterdir(): + if f.is_file() and f.name.startswith(f"Godot_v{version}"): + return f + + return Path() + + +def discover_artifacts(): + global GODOT_EDITOR, GODOT_RELEASE, GODOT_DEBUG + artifacts_dir = SCRIPT_DIR.parent / "godot-artifacts" + if not artifacts_dir.exists(): + return + + # Helper to find the best match for a pattern + def find_binary(pattern_part): + platform_part = "linux" if sys.platform.startswith("linux") else ("windows" if IS_WINDOWS else "macos") + + candidates = [] + for f in artifacts_dir.iterdir(): + if f.is_file() and platform_part in f.name and pattern_part in f.name: + candidates.append(f) + + if not candidates: + # Try without platform part as fallback + for f in artifacts_dir.iterdir(): + if f.is_file() and pattern_part in f.name: + candidates.append(f) + + if candidates: + # Prefer 'mono' if available, otherwise first one + for c in candidates: + if "mono" in c.name: + return str(c.resolve()) + return str(candidates[0].resolve()) + return None + + if GODOT_EDITOR == "unspecified-editor-binary": + found = find_binary("editor") + if found: + GODOT_EDITOR = found + + if GODOT_RELEASE == "unspecified-release-binary": + found = find_binary("template_release") + if found: + GODOT_RELEASE = found + + if GODOT_DEBUG == "unspecified-debug-binary": + found = find_binary("template_debug") + if found: + GODOT_DEBUG = found + + +# ---------------------------------------------- +# Helpers +# ---------------------------------------------- +def record_test_result(results: List[Dict[str, Any]], version: str, success: bool, duration: int) -> None: + status = "βœ… PASSED" if success else "❌ FAILED" + results.append({"godot_version": version or "master", "status": status, "duration_seconds": duration}) + + +def vprint(*args, **kwargs): + if verbose: + print(*args, **kwargs) + + +def filter_output(lines: List[str]) -> List[str]: + result = [] + for line in lines: + cleaned = line.rstrip() + if not cleaned: + continue + if any(pat.search(cleaned) for pat in FILTER_INCLUDE_PATTERNS): + result.append(cleaned) + continue + if any(pat.search(cleaned) for pat in FILTER_DISCARD_PATTERNS): + continue + result.append(cleaned) + return result + + +# ---------------------------------------------- +# Portable Godot +# ---------------------------------------------- + + +def setup_temp_portable_godot(): + vprint(f"\n{'-' * 10} Making Godot Portable {'-' * 10}") if verbose else print("β†’ Creating portable Godot", end=" ") + + vprint("β†’ Creating portable marker", end=" ") + try: + (Path.cwd() / PORTABLE_MARKER).touch(exist_ok=True) + vprint("[ DONE ]") + except OSError: + print("[ FAILED ]" if verbose else "[ Failed to create portable marker ]") + sys.exit(1) + + targets = [ + ("editor", GODOT_EDITOR, Path.cwd() / PORTABLE_EDITOR), + ("template_release", GODOT_RELEASE, Path.cwd() / PORTABLE_RELEASE), + ("template_debug", GODOT_DEBUG, Path.cwd() / PORTABLE_DEBUG), + ] + + for name, src, dst in targets: + vprint(f"β†’ Copying Godot {name}", end=" ") + try: + if ( + not src + or src == "unspecified-editor-binary" + or src == "unspecified-release-binary" + or src == "unspecified-debug-binary" + ): + vprint("[ SKIPPED (not provided) ]") + continue + + src_path = Path(src) + if not src_path.exists(): + vprint(f"[ SKIPPED (not found: {src}) ]") + continue + + src_fqp = src_path.resolve(True) + if src_fqp == dst.resolve(): + vprint("[ SKIPPED (same path) ]") + continue + shutil.copy2(src_fqp, dst) + + # If it's a .NET/Mono build, copy the GodotSharp directory too + src_godot_sharp = src_fqp.parent / "GodotSharp" + if src_godot_sharp.exists() and src_godot_sharp.is_dir(): + dst_godot_sharp = Path.cwd() / "GodotSharp" + if not dst_godot_sharp.exists(): + vprint(" (with GodotSharp)", end="") + shutil.copytree(src_godot_sharp, dst_godot_sharp) + + # Ensure executable bit on non-Windows + if not IS_WINDOWS: + dst.chmod(dst.stat().st_mode | 0o111) + vprint("[ DONE ]") + except OSError as e: + print(f"[ {e.strerror} ]") + print(f"\tβ†’ {e.filename}") + sys.exit(1) + + print("") if verbose else print("[ DONE ]") + + +def cleanup_temp_portable(): + temp_editor = Path.cwd() / PORTABLE_EDITOR + temp_release = Path.cwd() / PORTABLE_RELEASE + temp_debug = Path.cwd() / PORTABLE_DEBUG + temp_marker = Path.cwd() / PORTABLE_MARKER + temp_godot_sharp = Path.cwd() / "GodotSharp" + editor_data = Path.cwd() / "editor_data" + + cleaned = False + for path in [temp_editor, temp_release, temp_debug, temp_marker]: + if path.exists(): + try: + path.unlink() + cleaned = True + except OSError: + if verbose: + print(f"β†’ Failed to remove {path}") + + for path in [temp_godot_sharp, editor_data]: + if path.exists(): + try: + if path.is_file(): + path.unlink() + else: + shutil.rmtree(path) + cleaned = True + except OSError: + if verbose: + print(f"β†’ Failed to clean {path}") + + if cleaned: + print("β†’ Cleaned [ DONE ]") + + +# ---------------------------------------------- +# Cache & Run +# ---------------------------------------------- + + +def cleanup_godot_cache() -> bool: + cache_dir = PROJECT_DIR / ".godot" + if cache_dir.exists(): + if verbose: + print("β†’ Cleaning project cache", end=" ") + try: + shutil.rmtree(cache_dir, ignore_errors=True) + if verbose: + print("[ DONE ]") + except Exception as e: + if verbose: + print(f"[ FAILED ] {e}") + return False + return True + + +def run_godot(args: List[str], desc: str, godot_bin: str, timeout_sec: int = TIMEOUT_SEC) -> Tuple[int, str, str, str]: + if verbose: + print(f"\n{'-' * 10} {desc} {'-' * 10}") + print(f"β†’ {godot_bin} {' '.join(args)}") + + with tempfile.TemporaryDirectory() as tmpdir: + stdout_path = Path(tmpdir) / "stdout.txt" + stderr_path = Path(tmpdir) / "stderr.txt" + + cmd = [godot_bin] + args + + try: + start = time.time() + proc = subprocess.Popen( + cmd, + stdout=stdout_path.open("wb"), + stderr=stderr_path.open("wb"), + cwd=os.getcwd(), + start_new_session=True, + ) + + timeout = False + while proc.poll() is None: + if time.time() - start > timeout_sec: + proc.send_signal(signal.SIGTERM) + time.sleep(1) + if proc.poll() is None: + proc.kill() + proc.wait() + timeout = True + time.sleep(0.3) + + exit_code = proc.returncode + stdout = stdout_path.read_text("utf-8", errors="replace") + stderr = stderr_path.read_text("utf-8", errors="replace") + full_output = stdout + stderr + + if verbose: + print(full_output.rstrip()) + print(f"\n{'-' * 10} {desc} - exit:{exit_code:#x} {'-' * 10}") + + if timeout: + return 124, "TIMEOUT", f"After {timeout_sec}s", full_output + + return exit_code, "DONE", f"Exit code: {exit_code:#x}", full_output + + except Exception as exc: + stdout = stdout_path.read_text("utf-8", errors="replace") + stderr = stderr_path.read_text("utf-8", errors="replace") + full_output = stdout + stderr + + if verbose: + print(f"Failed to run Godot: {exc}") + print(full_output.rstrip()) + return 1, "EXCEPTION", f"{exc}", full_output + + +def pre_import_project(godot_bin: Path): + if not verbose: + print("β†’ Pre-Import", end=" ", flush=True) + + args = ["--path", str(PROJECT_DIR), "--import", "--headless"] + exit_code, strcode, msg, output = run_godot(args, "Pre-import", str(godot_bin), timeout_sec=IMPORT_TIMEOUT_SEC) + if not verbose: + # Show only summary / important parts + lines = output.splitlines() + filtered = filter_output(lines) + if filtered: + print("\n".join(filtered)) + + print(f"[ {strcode} ]", end=" ") + print(f"- {msg}" if msg else "") + return exit_code != 0 + + +def run_integration_tests(editor_bin: Path) -> bool: + print("β†’ Unit/Integration Tests", end=" ", flush=True) + + args = ["--path", str(PROJECT_DIR), "--debug", "--headless", "--quit"] + exitcode, strcode, msg, output = run_godot(args, "Unit/Integration tests", str(editor_bin)) + + def is_successful(output_text: str) -> bool: + return END_MARKER in output_text and PASSED_MARKER in output_text and FAILED_MARKER not in output_text + + if not verbose: + print(f"[ {strcode} ]", end=" ") + print(f"- {msg}" if msg else "") + + if exitcode == 124: + print("> Unit phase: TIMEOUT") + return False + elif not is_successful(output): + print("> Unit phase: FAILED") + return False + else: + return True + + +def generate_extension_docs(editor: Path) -> bool: + print("> GDExtension XML DocGen", end=" ", flush=True) + + doc_path = (PROJECT_DIR.parent / "doc_classes").resolve() + # Expected files based on test/src classes + expected_files = [ + "Example.xml", + "ExampleAbstractBase.xml", + "ExampleBase.xml", + "ExampleChild.xml", + "ExampleConcrete.xml", + "ExampleMin.xml", + "ExamplePrzykΕ‚ad.xml", + "ExampleRef.xml", + "ExampleRuntime.xml", + "ExampleVirtual.xml", + ] + + # Run from inside project/ (demo/), pointing --doctool at./ + args = [ + "--path", + str(PROJECT_DIR), + "--doctool", + "..", + "--gdextension-docs", + "--headless", + "--quit", + ] + exitcode, strcode, msg, output = run_godot(args, "GDExtension XML DocGen", str(editor)) + + # print the completion of the non-verbose line. + if not verbose: + print(f"[ {strcode} ]", end=" ") + print(f"- {msg}" if msg else "") + + if strcode == "TIMEOUT": + if verbose: + print("> DocGen phase: TIMEOUT") + return False + + if doc_path.exists(): + xml_files = [f.name for f in doc_path.glob("*.xml")] + missing = [f for f in expected_files if f not in xml_files] + if missing: + print(f"> DocGen phase: FAILED (Missing files: {', '.join(missing)})") + return False + + if len(xml_files) > 0: + if verbose: + print(f"> DocGen doc_classes/ created at: {doc_path} ({len(xml_files)} XML files)") + for file in doc_path.glob("*.xml"): + print(file) + return True + if verbose: + print("> Warning: DocGen Command succeeded but no doc_classes/*.xml found") + return False + else: + print("> DocGen phase: FAILED (Directory not found)") + return False + + +def cleanup_docs(): + doc_path = (PROJECT_DIR.parent / "doc_classes").resolve() + if doc_path.exists(): + try: + shutil.rmtree(doc_path) + print("> Cleaned doc_classes [ DONE ]") + except OSError: + if verbose: + print(f"> Failed to remove {doc_path}") + + +# ---------------------------------------------- +# Main +# ---------------------------------------------- +def main(): + global verbose + + parser = argparse.ArgumentParser(description="Run godot-cpp test suite") + parser.add_argument( + "--tests-only", + action="store_const", + const="unit", + dest="mode", + help="Only run the integration tests (skip doc xml generation)", + ) + parser.add_argument( + "--docs-only", + action="store_const", + const="docs", + dest="mode", + help="Only generate GDExtension XML documentation (skip tests)", + ) + parser.add_argument("--verbose", action="store_true", default=False, help="Show full unfiltered Godot output") + parser.add_argument( + "--quiet", action="store_true", default=False, help="Only exit code (0=success, >0=failure); no output" + ) + parser.add_argument( + "--godot-test-versions", + type=str, + default="", + help="Space-separated list of Godot versions to download and test", + ) + parser.add_argument( + "--godot-test-arch", + type=str, + default="", + help="Architecture for downloading Godot (e.g., linux.x86_64, win64.exe, macos.universal)", + ) + args = parser.parse_args() + + # store a reference to print + original_print = builtins.print + + mode = args.mode or "full" + verbose = args.verbose + + if args.quiet: + + def silent(*_args, **_kwargs): + pass + + builtins.print = silent + else: + builtins.print = original_print # restore just in case + + if args.quiet and args.verbose: + print("--quiet takes precedence over --verbose", file=sys.stderr) + verbose = False + + versions = args.godot_test_versions.split() + arch = args.godot_test_arch + + main_version = os.environ.get("GODOT_TEST_VERSION", "master") + if main_version != "master": + if main_version not in versions: + versions.insert(0, main_version) + + if not versions or main_version == "master": + if None not in versions: + versions.insert(0, None) + + overall_success = True + + results = [] # list of dicts for table + JSON + + for version in versions: + global GODOT_EDITOR, GODOT_RELEASE, GODOT_DEBUG + if version: + print(f"--- Testing against Godot {version} ({arch}) ---") + godot_bin = download_godot(version, arch) + if not godot_bin: + overall_success = False + record_test_result(results, version, False, 0) + continue + + GODOT_EDITOR = str(godot_bin) + GODOT_RELEASE = str(godot_bin) + GODOT_DEBUG = str(godot_bin) + else: + discover_artifacts() + + editor_bin: Path = Path.cwd() / PORTABLE_EDITOR + print(f"editor: {GODOT_EDITOR}") + print(f"template_release: {GODOT_RELEASE}") + print(f"template_debug: {GODOT_DEBUG}") + print(f"Project: {PROJECT_DIR}") + print(f"Mode: {mode}") + print(f"Verbose: {verbose}\n") + + start_time = time.time() + + setup_temp_portable_godot() + _ = cleanup_godot_cache() + _ = pre_import_project(editor_bin) + + success = True + # Perform Integration Testing + if mode in ("unit", "full") and success: + success = run_integration_tests(editor_bin) + + if mode in ("docs", "full") and success: + success = generate_extension_docs(editor_bin) + + cleanup_temp_portable() + cleanup_docs() + + duration = int(time.time() - start_time) + record_test_result(results, version, success, duration) + + if not success: + overall_success = False + + print("-" * 80) + status = "PASSED" if success else "FAILED" + print(f"TEST SUITE ({version or 'master'}) {status} - took {duration}s") + + # --- Summary table and metrics artifact --- + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path and results: + try: + with open(summary_path, "a", encoding="utf-8") as f: + platform = os.environ.get("RUNNER_OS", "unknown") + f.write(f"\n## Test Results - {platform} ({os.environ.get('RUNNER_OS', 'unknown')})\n\n") + f.write("| Godot Version | Status | Duration (s) |\n") + f.write("|---------------|------------|--------------|\n") + for r in results: + f.write(f"| {r['godot_version']:13} | {r['status']:10} | {r['duration_seconds']:12} |\n") + except Exception: + pass # ignore summary write failures + + # Write JSON artifact for historical tracking + metrics_path = SCRIPT_DIR / "test-metrics.json" + try: + import json + + with open(metrics_path, "w", encoding="utf-8") as f: + json.dump( + { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), + "platform": os.environ.get("RUNNER_OS", "unknown"), + "runner": os.environ.get("RUNNER_NAME", "unknown"), + "results": results, + }, + f, + indent=2, + ) + except Exception: + pass + + builtins.print = original_print + sys.exit(0 if overall_success else 3) + + +if __name__ == "__main__": + main()