diff --git a/.github/scripts/ci_utils.sh b/.github/scripts/ci_utils.sh new file mode 100644 index 00000000..3c7ef4d2 --- /dev/null +++ b/.github/scripts/ci_utils.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# .github/scripts/ci_utils.sh +# +# Utility functions for local CI simulation and GitHub Actions. +# This script is designed to be sourced by other bash scripts to provide +# shared, cross-platform functionality. + +# ----------------------------------------------------------------------------- +# Function: sedi +# Description: A cross-platform wrapper for 'sed' in-place file editing. +# +# Why this is needed: +# GNU 'sed' (Linux, WSL, Git Bash on Windows) uses: sed -i '...' +# BSD 'sed' (macOS/Darwin) requires an empty string extension: sed -i '' '...' +# This function checks the OS and automatically applies the correct syntax +# so the script doesn't crash or corrupt files for developers on Macs. +# ----------------------------------------------------------------------------- +sedi() { + if [ "$(uname)" = "Darwin" ]; then + sed -i '' "$@" + else + sed -i "$@" + fi +} + +# ----------------------------------------------------------------------------- +# Function: disable_editor_plugins +# Description: Safely strips editor plugins from the project configuration. +# +# Why this is needed: +# Running Godot headless exports on CI runners (like Ubuntu) can sometimes cause +# Signal 11 crashes if UI-dependent plugins (like GUT) attempt to initialize. +# This function isolates the [editor_plugins] block in the project config +# and clears the 'enabled' array to ensure a safe, headless build. +# +# Arguments: +# $1 - (Optional) Path to the Godot project file. +# Defaults to "project.godot" in the current working directory. +# ----------------------------------------------------------------------------- +disable_editor_plugins() { + local target_file="${1:-project.godot}" + echo "๐Ÿ”Œ Disabling editor plugins in $target_file to prevent headless crashes..." + sedi '/^\[editor_plugins\]/,/^\[/ s/^enabled=PackedStringArray.*/enabled=PackedStringArray()/' "$target_file" +} diff --git a/.github/scripts/inject_ci_flag.py b/.github/scripts/inject_ci_flag.py new file mode 100644 index 00000000..e83b82b7 --- /dev/null +++ b/.github/scripts/inject_ci_flag.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +Infrastructure utility for Godot 4 Web exports. + +This script modifies 'export_presets.cfg' to inject a custom 'ci' feature flag. +""" + +import re +import shutil +import sys +from pathlib import Path + +# Force UTF-8 encoding for stdout to prevent crashes on Windows legacy terminals. +# Safely fallback to "" if stdout is redirected (e.g., in CI) and encoding is None. +stdout_enc = sys.stdout.encoding or "" +if stdout_enc.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + # Proceed safely if reconfigure is unsupported in the current piped environment + pass + + +def inject_ci_flag(): + """ + Parses export_presets.cfg and injects the 'ci' flag into custom_features. + Uses a 'Strip and Append' strategy to safely handle multiple presets. + """ + config_path = Path("export_presets.cfg") + backup_path = Path("export_presets.cfg.bak") + + try: + if config_path.exists(): + if not backup_path.exists(): + shutil.copy2(config_path, backup_path) + else: + print( + f"โŒ ERROR: {config_path} not found. Ensure you are in the project root." + ) + sys.exit(1) + + data = config_path.read_text(encoding="utf-8") + + # 1. Strip out ANY existing custom_features lines to ensure a clean slate. + updated_data = re.sub(r"(?m)^custom_features=.*\r?\n?", "", data) + + # 2. Inject the 'ci' flag directly under EVERY root preset header. + # This matches [preset.0], [preset.1] etc., but explicitly avoids [preset.0.options] + updated_data = re.sub( + r"(?m)^(\[preset\.\d+\])\s*$", r'\1\ncustom_features="ci"', updated_data + ) + + config_path.write_text(updated_data, encoding="utf-8") + + print("โœ… Successfully injected 'ci' feature flag into export_presets.cfg") + + except Exception as e: + try: + print(f"โŒ Failed to inject 'ci' flag: {e}") + except UnicodeEncodeError: + print(f"Failed to inject 'ci' flag (encoding error in logs): {e}") + sys.exit(1) + + +if __name__ == "__main__": + inject_ci_flag() diff --git a/.github/scripts/inject_salt.sh b/.github/scripts/inject_salt.sh index 8ea16b5b..ebede026 100644 --- a/.github/scripts/inject_salt.sh +++ b/.github/scripts/inject_salt.sh @@ -1,53 +1,34 @@ #!/bin/bash +# .github/scripts/inject_salt.sh -# $1 is the filename passed from Python (e.g., "dummy.godot") TARGET_FILE="$1" if [ -z "$TARGET_FILE" ]; then - echo "Error: No file path provided." + echo "โŒ ERROR: No target file provided." exit 1 fi -awk ' -BEGIN { - salt = ENVIRON["SALT"] - in_game = 0 - salt_written = 0 - saw_game_section = 0 -} -{ - if ($0 ~ /^\[game\]/) { - in_game = 1 - saw_game_section = 1 - print - next - } else if ($0 ~ /^\[/ && $0 !~ /^\[game\]/) { - if (in_game && !salt_written) { - print "security/save_salt=\"" salt "\"" - salt_written = 1 - } - in_game = 0 - print - next - } - if (in_game && $0 ~ /^[[:space:]]*security\/save_salt[[:space:]]*=/) { - if (!salt_written) { - print "security/save_salt=\"" salt "\"" - salt_written = 1 - } - next - } - print -} -END { - if (in_game && !salt_written) { - print "security/save_salt=\"" salt "\"" - salt_written = 1 - } - if (!saw_game_section) { - if (NR > 0) { print "" } - print "[game]" - print "security/save_salt=\"" salt "\"" - } -} -' "$TARGET_FILE" > "${TARGET_FILE}.tmp" && mv "${TARGET_FILE}.tmp" "$TARGET_FILE" \ No newline at end of file +if [ ! -f "$TARGET_FILE" ]; then + echo "โŒ ERROR: Target file '$TARGET_FILE' does not exist." + exit 1 +fi + +if [ -z "$PRODUCTION_SALT" ]; then + echo "โŒ ERROR: PRODUCTION_SALT environment variable is not set." + exit 1 +fi + +echo "โš™๏ธ Injecting secret into $TARGET_FILE..." + +# 1. Escape for Godot's parser +GODOT_ESCAPED=$(printf '%s' "$PRODUCTION_SALT" | sed 's/\\/\\\\/g; s/"/\\"/g') + +# 2. Escape for sed replacement string +SED_ESCAPED=$(printf '%s' "$GODOT_ESCAPED" | sed 's/\\/\\\\/g; s/&/\\&/g; s/|/\\|/g') + +# Cross-platform sed for in-place editing (macOS vs Linux) +# Source the shared utilities +source "$(dirname "$0")/ci_utils.sh" + +# 3. Replace the safe placeholder with the real secret +sedi "s|\"CI_INJECT_SALT_HERE\"|\"$SED_ESCAPED\"|g" "$TARGET_FILE" diff --git a/.github/workflows/browser_test.yml b/.github/workflows/browser_test.yml index 1d35cb5d..480d7315 100644 --- a/.github/workflows/browser_test.yml +++ b/.github/workflows/browser_test.yml @@ -23,14 +23,21 @@ jobs: steps: - uses: "actions/checkout@v6.0.1" - - name: "Inject Test Salt" - # Ensures Godot headless export doesn't strip the property, keeping encryption active for tests + - name: "Disable Editor Plugins (Prevent Headless Crash)" run: | - if ! grep -q '\[game\]' project.godot; then - printf '\n[game]\nsecurity/save_salt="test_salt_12345"\n' >> project.godot - else - sed -i '/^\[game\]/a security/save_salt="test_salt_12345"' project.godot - fi + source .github/scripts/ci_utils.sh + disable_editor_plugins "project.godot" + + - name: "Inject Test Salt into GDScript" + env: + PRODUCTION_SALT: "playwright_dummy_salt_123" + run: | + # Executes the Single Source of Truth script + bash ./.github/scripts/inject_salt.sh "scripts/core/globals.gd" + + - name: "Inject 'ci' feature flag into export_presets.cfg" + run: | + python3 .github/scripts/inject_ci_flag.py - name: "Create Export Directories" run: | @@ -93,11 +100,26 @@ jobs: - name: "Install Playwright Browsers" if: steps.playwright-cache.outputs.cache-hit != 'true' run: | - playwright install --with-deps chromium + playwright install --with-deps chromium - - name: "Start WEB Server" + - name: "Start Security-Isolated HTTP Server" run: | - python -m http.server 8080 --directory export/web_thread_off & + python3 -c " + import http.server, socketserver, os + class MyHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cross-Origin-Opener-Policy', 'same-origin') + self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + super().end_headers() + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(('', 8080), MyHandler) as httpd: + os.chdir('export/web_thread_off') + print('๐Ÿš€ Security-isolated server starting on port 8080...') + httpd.serve_forever() + " & + sleep 5 + curl -I http://localhost:8080/index.html - name: "Wait For WEB Server Response" run: | @@ -112,7 +134,7 @@ jobs: - name: "Run Tests" run: | - PYTHONPATH="$PWD/tests:$PYTHONPATH" pytest tests/ -v --ignore=tests/refactor --junitxml=junit.xml -o junit_family=legacy + PYTHONPATH="$PWD/tests:$PYTHONPATH" pytest tests/ -v --ignore=tests/refactor --ignore=tests/ci --junitxml=junit.xml -o junit_family=legacy - name: "List Coverage Reports" run: | diff --git a/.github/workflows/deploy_to_itch.yml b/.github/workflows/deploy_to_itch.yml index 1ce501bb..fe4e7449 100644 --- a/.github/workflows/deploy_to_itch.yml +++ b/.github/workflows/deploy_to_itch.yml @@ -36,56 +36,15 @@ jobs: printf '\n[application]\nconfig/version="%s"\n' "$ESCAPED_VERSION" >> project.godot fi - - name: "Inject Production Salt into project.godot" - env: - RAW_SECRET: ${{ secrets.PRODUCTION_SALT }} + - name: "Disable Editor Plugins (Prevent Headless Crash)" run: | - # 1. Escape backslashes and quotes for Godot's config format[cite: 14] - ESCAPED_SALT=$(printf '%s' "$RAW_SECRET" | sed 's/\\/\\\\/g; s/"/\\"/g') - # 2. Use a section-aware awk script to update security/save_salt[cite: 14] - # Pass the salt directly into awk to avoid broad environment exposure[cite: 14] - awk -v salt="$ESCAPED_SALT" ' - BEGIN { - in_game = 0 - salt_written = 0 - saw_game_section = 0 - } - { - if ($0 ~ /^\[game\]/) { - in_game = 1 - saw_game_section = 1 - print - next - } else if ($0 ~ /^\[/ && $0 !~ /^\[game\]/) { - if (in_game && !salt_written) { - print "security/save_salt=\"" salt "\"" - salt_written = 1 - } - in_game = 0 - print - next - } - if (in_game && $0 ~ /^[[:space:]]*security\/save_salt[[:space:]]*=/) { - if (!salt_written) { - print "security/save_salt=\"" salt "\"" - salt_written = 1 - } - next - } - print - } - END { - if (in_game && !salt_written) { - print "security/save_salt=\"" salt "\"" - salt_written = 1 - } - if (!saw_game_section) { - if (NR > 0) { print "" } - print "[game]" - print "security/save_salt=\"" salt "\"" - } - } - ' project.godot > project.godot.tmp && mv project.godot.tmp project.godot + source .github/scripts/ci_utils.sh + disable_editor_plugins "project.godot" + + - name: "Inject Production Salt into GDScript" + env: + PRODUCTION_SALT: ${{ secrets.PRODUCTION_SALT }} + run: bash ./.github/scripts/inject_salt.sh "scripts/core/globals.gd" - name: "Create Export Directories" run: mkdir -p export/web diff --git a/.github/workflows/lint_readme.yml b/.github/workflows/lint_readme.yml index 22ae9e2a..3c84ffd8 100644 --- a/.github/workflows/lint_readme.yml +++ b/.github/workflows/lint_readme.yml @@ -21,7 +21,7 @@ jobs: # Lints Markdown for formatting/spelling # Pinned to SHA for security (was @v19) # yamllint disable rule:line-length - uses: "DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9" + uses: "DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd" # yamllint enable rule:line-length with: config: ".markdownlint-cli2.yaml" diff --git a/.github/workflows/release_drafter.yml b/.github/workflows/release_drafter.yml index d5a194f3..7dffb2d3 100644 --- a/.github/workflows/release_drafter.yml +++ b/.github/workflows/release_drafter.yml @@ -26,7 +26,7 @@ jobs: tag_name: "${{ steps.drafter.outputs.tag_name }}" # Expose from step steps: # yamllint disable-line rule:line-length - - uses: "release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32" + - uses: "release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927" id: drafter # Add ID to access outputs with: config-name: "release-drafter.yml" # Explicitly point to your config diff --git a/.github/workflows/release_drafter_pr.yml b/.github/workflows/release_drafter_pr.yml index a1adb7c6..e3c787ab 100644 --- a/.github/workflows/release_drafter_pr.yml +++ b/.github/workflows/release_drafter_pr.yml @@ -16,7 +16,7 @@ jobs: runs-on: "ubuntu-latest" steps: # yamllint disable-line rule:line-length - - uses: "release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32" + - uses: "release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927" with: config-name: "release-drafter.yml" # Your existing config env: diff --git a/.github/workflows/test_ci_scripts.yml b/.github/workflows/test_ci_scripts.yml index c632bb0f..274b1d02 100644 --- a/.github/workflows/test_ci_scripts.yml +++ b/.github/workflows/test_ci_scripts.yml @@ -18,6 +18,11 @@ jobs: with: python-version: "3.12" - - name: "Run Salt Injection Test" + - name: "Install Dependencies" run: | - python tests/ci/test_salt_injection.py + python -m pip install --upgrade pip + pip install pytest playwright + + - name: "Run CI Injection Tests" + run: | + pytest tests/ci/ -v -s diff --git a/project.godot b/project.godot index e83c5a61..14442c96 100644 --- a/project.godot +++ b/project.godot @@ -50,11 +50,11 @@ window/vsync/vsync_mode=0 [editor_plugins] -enabled=PackedStringArray("res://addons/ai_autonomous_agent/plugin.cfg", "res://addons/gut/plugin.cfg") +enabled=PackedStringArray("res://addons/gut/plugin.cfg") [game] -security/save_salt="" +security/save_salt="dev_fallback_salt" [gdunit4] diff --git a/requirements.txt b/requirements.txt index 985e48fa..739fa536 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,4 @@ setuptools==80.10.2 six==1.17.0 text-unidecode==1.3 typing_extensions==4.15.0 -urllib3==2.6.3 +urllib3==2.7.0 diff --git a/scripts/core/globals.gd b/scripts/core/globals.gd index ed5c4c8f..311be9ed 100644 --- a/scripts/core/globals.gd +++ b/scripts/core/globals.gd @@ -458,29 +458,34 @@ func ensure_encryption_key() -> String: ## Generates a unique, deterministic encryption key for local save files. ## Generates a unique, deterministic encryption key for local save files. func _get_encryption_key() -> String: - var salt: String = ProjectSettings.get_setting("game/security/save_salt", "dev_fallback_salt") + # Safe placeholder. This is an open source repo, so the REAL salt + # is injected by GitHub Actions / CI pipeline during the build process. + var salt: String = "CI_INJECT_SALT_HERE" - # 1. FAILSAFE: If the salt is literally empty, always abort to plaintext + # 1. FAILSAFE: If the salt is literally empty, always abort if salt.is_empty(): - log_message( - "๐Ÿšจ ENCRYPTION ABORTED: Salt is empty. Falling back to plaintext.", LogLevel.WARNING - ) + log_message("๐Ÿšจ ENCRYPTION ABORTED: Salt is empty.", LogLevel.WARNING) return "" - var is_automated_test: bool = false - if OS.has_feature("web"): - is_automated_test = JavaScriptBridge.eval("navigator.webdriver") == true - # 2. SECURITY GUARD: Prevent silent weak-key fallback in production. + # We use a custom "ci" feature flag instead of a blanket "web" check + # to keep the production security guard fully active on itch.io. + var is_automated_test: bool = OS.has_feature("ci") + log_message( + "โš™๏ธ ENV CHECK (is_automated_test): " + str(is_automated_test), Globals.LogLevel.DEBUG + ) + if not OS.has_feature("editor") and not OS.has_feature("debug") and not is_automated_test: - if salt == "dev_fallback_salt": - var error_msg: String = "CRITICAL SECURITY ERROR: Missing or weak salt." + if salt == "CI_INJECT_SALT_HERE": + var error_msg: String = "CRITICAL SECURITY ERROR: Missing production salt." push_error(error_msg) OS.crash(error_msg) return "" - # FIX: OS.get_unique_id() crashes on Web + + # FIX: Removed JavaScriptBridge.eval() from here. Calling JS from a + # class-level variable initialization silently crashes the WebAssembly module! var device_id: String = "web_fallback" - if OS.get_name() != "Web": + if not OS.has_feature("web"): device_id = OS.get_unique_id() return (device_id + salt).sha256_text() @@ -502,6 +507,8 @@ func is_file_encrypted(path: String) -> bool: return magic == 0x43454447 +## Safely loads a config file, handling both encrypted and legacy plaintext formats. +## Returns a Dictionary: {"config": ConfigFile, "err": int, "is_legacy": bool} ## Safely loads a config file, handling both encrypted and legacy plaintext formats. ## Returns a Dictionary: {"config": ConfigFile, "err": int, "is_legacy": bool} func safe_load_config(path: String) -> Dictionary: @@ -520,15 +527,42 @@ func safe_load_config(path: String) -> Dictionary: err = config.load_encrypted_pass(path, key) if err != OK: log_message( - ( - "๐Ÿšจ DECRYPTION FAILED for file: " - + path - + " (Error code: " - + str(err) - + "). Key mismatch or corrupted file!" - ), + "๐Ÿšจ DECRYPTION FAILED for file: " + path + " (Error code: " + str(err) + ").", LogLevel.ERROR ) + + # --- AUTO-RECOVERY FIX: TIGHTENED CONDITIONS --- + # Only delete if the file is explicitly corrupted or has invalid data (bad password/hash). + if err == ERR_FILE_CORRUPT or err == ERR_INVALID_DATA: + log_message( + "๐Ÿ—‘๏ธ Attempting to auto-delete corrupted/orphaned save file to allow clean recovery.", + LogLevel.INFO + ) + + var remove_err: int = DirAccess.remove_absolute(path) + if remove_err == OK: + log_message("โœ… Corrupted file successfully deleted.", LogLevel.DEBUG) + # Treat as a first-time boot so the game generates fresh defaults + err = ERR_FILE_NOT_FOUND + else: + log_message( + ( + "โŒ FAILED to delete corrupted file at " + + path + + " (Error: " + + str(remove_err) + + "). Game may be unable to save!" + ), + LogLevel.ERROR + ) + else: + log_message( + ( + "โš ๏ธ File is unreadable but NOT explicitly corrupted. " + + "Skipping auto-deletion to prevent accidental data loss." + ), + LogLevel.WARNING + ) else: log_message("๐Ÿ”“ Successfully decrypted file: " + path, LogLevel.DEBUG) else: diff --git a/test/gut/test_deduplication_on_device_switch.gd b/test/gut/test_deduplication_on_device_switch.gd index 2c556414..ca13fa5b 100644 --- a/test/gut/test_deduplication_on_device_switch.gd +++ b/test/gut/test_deduplication_on_device_switch.gd @@ -16,6 +16,10 @@ var button: InputRemapButton ## Per-test: Setup button with listening. func before_each() -> void: + # CLEANUP: Prevent dirty state from previous encryption tests in CI/CD + if FileAccess.file_exists("user://settings.cfg"): + DirAccess.remove_absolute("user://settings.cfg") + InputMap.action_erase_events(TEST_ACTION) if not InputMap.has_action(TEST_ACTION): InputMap.add_action(TEST_ACTION) @@ -29,6 +33,9 @@ func before_each() -> void: func after_each() -> void: InputMap.action_erase_events(TEST_ACTION) + # CLEANUP: Leave the virtual environment pristine for the next test + if FileAccess.file_exists("user://settings.cfg"): + DirAccess.remove_absolute("user://settings.cfg") ## DEDUP-07 | Device switch mid-remap โ†’ input on new device, no dup on old | Correct event, no extras diff --git a/test/gut/test_encryption_failsafe.gd b/test/gut/test_encryption_failsafe.gd index 57eb4090..ef5ffb6a 100644 --- a/test/gut/test_encryption_failsafe.gd +++ b/test/gut/test_encryption_failsafe.gd @@ -3,99 +3,58 @@ ## test_encryption_failsafe.gd ## ## GUT unit tests for the Settings Encryption Failsafe. -## Covers the specific edge case where the CI/CD pipeline injects a salt, -## but Godot's headless exporter strips it due to un-registered ProjectSettings. -## Verifies that missing salts trigger plaintext fallback, and valid salts encrypt. +## Updated for the GDScript Bytecode Injection architecture. +## Verifies that the hardcoded salt successfully encrypts the file. extends GutTest const TEST_CONFIG_PATH: String = "user://test_encryption_failsafe.cfg" -const SALT_PROPERTY: String = "game/security/save_salt" -var _original_salt_value: Variant = null -var _original_salt_existed: bool = false var _original_settings: GameSettingsResource +var _original_key: String -## Per-test setup: Isolate the filesystem, backup ProjectSettings, and setup Globals. +## Per-test setup: Isolate the filesystem and setup Globals. func before_each() -> void: if FileAccess.file_exists(TEST_CONFIG_PATH): DirAccess.remove_absolute(TEST_CONFIG_PATH) - _original_salt_existed = ProjectSettings.has_setting(SALT_PROPERTY) - if _original_salt_existed: - _original_salt_value = ProjectSettings.get_setting(SALT_PROPERTY) - _original_settings = Globals.settings Globals.settings = GameSettingsResource.new() - # FIX: Wipe the cached key to force re-evaluation for the failsafe tests + # Wipe the cached key to force re-evaluation + _original_key = Globals.save_encryption_pass Globals.save_encryption_pass = "" -## Per-test cleanup: Restore ProjectSettings and remove temporary files. +## Per-test cleanup: Restore Globals and remove temporary files. func after_each() -> void: Globals.settings = _original_settings + Globals.save_encryption_pass = _original_key - if _original_salt_existed: - ProjectSettings.set_setting(SALT_PROPERTY, _original_salt_value) - else: - ProjectSettings.clear(SALT_PROPERTY) - - # FIX: Clean up cached key so it doesn't bleed into other GUT scripts - Globals.save_encryption_pass = "" - if FileAccess.file_exists(TEST_CONFIG_PATH): DirAccess.remove_absolute(TEST_CONFIG_PATH) -## TEST 1: The Root Cause Validation -func test_ci_salt_property_is_registered_in_engine() -> void: - gut.p("Testing: 'game/security/save_salt' must be a registered Project Setting to survive CI export.") +## TEST 1: The New Architecture Validation +func test_bytecode_salt_generates_valid_key() -> void: + gut.p("Testing: 'ensure_encryption_key' generates a valid hash from the bytecode salt.") - var property_list: Array[Dictionary] = ProjectSettings.get_property_list() - var property_found: bool = false + var generated_key: String = Globals.ensure_encryption_key() - for prop: Dictionary in property_list: - if prop["name"] == SALT_PROPERTY: - property_found = true - break - - assert_true(property_found, "CRITICAL: The salt property is not registered in the Godot Editor. The CI pipeline will strip it during export!") + assert_false(generated_key.is_empty(), "CRITICAL: The generated key should not be empty.") + # SHA-256 hashes are exactly 64 characters long + assert_eq(generated_key.length(), 64, "The generated key should be a valid SHA-256 hash string.") -## TEST 2: The Bug Scenario (Missing Salt) -func test_failsafe_saves_plaintext_when_salt_is_missing() -> void: - gut.p("Testing: Missing salt triggers failsafe and saves file as plaintext.") - - ProjectSettings.set_setting(SALT_PROPERTY, "") +## TEST 2: The Intended Scenario (Encryption Active) +func test_save_encrypts_file_with_valid_key() -> void: + gut.p("Testing: Valid key successfully encrypts the settings file.") Globals._save_settings(TEST_CONFIG_PATH) assert_true(FileAccess.file_exists(TEST_CONFIG_PATH), "Settings file should have been created.") - var file: FileAccess = FileAccess.open(TEST_CONFIG_PATH, FileAccess.READ) - assert_not_null(file, "Should be able to open the file.") - - var first_line: String = file.get_line() - file.close() - - assert_true(first_line.begins_with("["), "Failsafe failed: File should be readable plaintext starting with '[' when salt is missing.") - - -## TEST 3: The Intended Scenario (Salt Present) -func test_save_encrypts_file_when_salt_is_present() -> void: - gut.p("Testing: Valid salt successfully encrypts the settings file.") - - ProjectSettings.set_setting(SALT_PROPERTY, "valid_test_salt_12345") - - Globals._save_settings(TEST_CONFIG_PATH) - - assert_true(FileAccess.file_exists(TEST_CONFIG_PATH), "Settings file should have been created.") - - var file: FileAccess = FileAccess.open(TEST_CONFIG_PATH, FileAccess.READ) - assert_not_null(file, "Should be able to open the file.") - - var first_line: String = file.get_line() - file.close() - - assert_false(first_line.begins_with("["), "Encryption failed: File is still readable plaintext despite having a valid salt.") + # Instead of reading as UTF-8 text and triggering console errors, + # we safely check the binary magic number. + var is_encrypted: bool = Globals.is_file_encrypted(TEST_CONFIG_PATH) + assert_true(is_encrypted, "Encryption failed: The file does not have the encrypted file magic number.") diff --git a/test/gut/test_encryption_logging.gd b/test/gut/test_encryption_logging.gd index 544c3e9d..ee6291c0 100644 --- a/test/gut/test_encryption_logging.gd +++ b/test/gut/test_encryption_logging.gd @@ -10,23 +10,14 @@ extends GutTest const TEST_CONFIG_PATH: String = "user://test_encryption_logging.cfg" const INVALID_CONFIG_PATH: String = "user://invalid_directory_that_does_not_exist/test.cfg" -const SALT_PROPERTY: String = "game/security/save_salt" -const DUMMY_SALT: String = "test_logging_salt_123" -var _original_salt_value: Variant = null -var _original_salt_existed: bool = false var _original_settings: GameSettingsResource + func before_each() -> void: if FileAccess.file_exists(TEST_CONFIG_PATH): DirAccess.remove_absolute(TEST_CONFIG_PATH) - _original_salt_existed = ProjectSettings.has_setting(SALT_PROPERTY) - if _original_salt_existed: - _original_salt_value = ProjectSettings.get_setting(SALT_PROPERTY) - - ProjectSettings.set_setting(SALT_PROPERTY, DUMMY_SALT) - # Wipe cached key to force re-generation Globals.save_encryption_pass = "" @@ -36,14 +27,9 @@ func before_each() -> void: # Force log level to DEBUG so we see everything in the console Globals.settings.current_log_level = Globals.LogLevel.DEBUG + func after_each() -> void: Globals.settings = _original_settings - - if _original_salt_existed: - ProjectSettings.set_setting(SALT_PROPERTY, _original_salt_value) - else: - ProjectSettings.clear(SALT_PROPERTY) - Globals.save_encryption_pass = "" if FileAccess.file_exists(TEST_CONFIG_PATH): diff --git a/test/gut/test_get_pause_binding_label_for_device.gd b/test/gut/test_get_pause_binding_label_for_device.gd index 791de58a..c0467bde 100644 --- a/test/gut/test_get_pause_binding_label_for_device.gd +++ b/test/gut/test_get_pause_binding_label_for_device.gd @@ -6,7 +6,12 @@ extends GutTest var settings: Node + func before_each() -> void: + # CLEANUP: Prevent dirty state from previous encryption tests in CI/CD + if FileAccess.file_exists("user://settings.cfg"): + DirAccess.remove_absolute("user://settings.cfg") + settings = preload(GamePaths.SETTINGS).new() add_child_autofree(settings) @@ -14,8 +19,12 @@ func before_each() -> void: InputMap.erase_action("pause") InputMap.add_action("pause") + func after_each() -> void: await get_tree().process_frame + # CLEANUP: Leave the virtual environment pristine for the next test + if FileAccess.file_exists("user://settings.cfg"): + DirAccess.remove_absolute("user://settings.cfg") # ============================================================ # KEYBOARD TESTS diff --git a/test/gut/test_input_remap_button.gd b/test/gut/test_input_remap_button.gd index 9a030e6e..4ffe85ab 100644 --- a/test/gut/test_input_remap_button.gd +++ b/test/gut/test_input_remap_button.gd @@ -17,6 +17,10 @@ const TEST_ACTION: String = "test_action" ## Per-test setup: Reset InputMap for test action, instantiate button. ## :rtype: void func before_each() -> void: + # CLEANUP: Prevent dirty state from previous encryption tests in CI/CD + if FileAccess.file_exists("user://settings.cfg"): + DirAccess.remove_absolute("user://settings.cfg") + if InputMap.has_action(TEST_ACTION): InputMap.erase_action(TEST_ACTION) InputMap.add_action(TEST_ACTION) @@ -32,6 +36,10 @@ func after_each() -> void: if InputMap.has_action(TEST_ACTION): InputMap.erase_action(TEST_ACTION) await get_tree().process_frame + + # CLEANUP: Leave the virtual environment pristine for the next test + if FileAccess.file_exists("user://settings.cfg"): + DirAccess.remove_absolute("user://settings.cfg") ## IRB-01 | Remap keyboard event | current_device = KEYBOARD; action exists with prior events | Instantiate, simulate _input with Key, inspect InputMap | Only keyboard event added; old erased; button label updated; remap logged. diff --git a/tests/audio_flow_test.py b/tests/audio_flow_test.py index 74d09f14..56ea0d84 100644 --- a/tests/audio_flow_test.py +++ b/tests/audio_flow_test.py @@ -78,7 +78,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) # Verify canvas @@ -118,7 +118,7 @@ def on_console(msg) -> None: # Set log level DEBUG pre_change_log_count = len(logs) page.evaluate("window.changeLogLevel([0])") - page.wait_for_timeout(1000) + page.wait_for_timeout(TEST_TIMEOUT) new_logs = logs[pre_change_log_count:] assert any( "log level changed to: debug" in log["text"].lower() for log in new_logs @@ -145,7 +145,7 @@ def on_console(msg) -> None: "window.audioPressed !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.audioPressed([])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) assert ( page.evaluate( "window.getComputedStyle(document.getElementById('master-slider')).display" @@ -173,7 +173,7 @@ def on_console(msg) -> None: "window.toggleMuteMaster !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.toggleMuteMaster([0])") # Mute - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) new_logs = logs[pre_change_log_count:] assert any("master is muted" in log["text"].lower() for log in new_logs) # Change SFX Volume when Master is muted @@ -182,7 +182,7 @@ def on_console(msg) -> None: "window.changeSfxVolume !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.changeSfxVolume([0])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) assert ( page.evaluate("document.getElementById('sfx-slider').value") == initial_sfx ), "SFX value changed unexpectedly" @@ -194,18 +194,12 @@ def on_console(msg) -> None: # Additional: Master muted โ†’ attempt sub-volume adjust (Music) # Attempt to change music while Master is still muted - # page.evaluate(""" - # const slider = document.getElementById('music-slider'); - # slider.value = 0.3; - # slider.dispatchEvent(new Event('input')); - # slider.dispatchEvent(new Event('change')); - # """) pre_change_log_count = len(logs) page.wait_for_function( "window.changeMusicVolume !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.changeMusicVolume([0.3])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) assert ( page.evaluate("document.getElementById('music-slider').value") == initial_music @@ -223,7 +217,7 @@ def on_console(msg) -> None: "window.changeRotorsVolume !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.changeRotorsVolume([0.4])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) assert ( page.evaluate("document.getElementById('rotors-slider').value") == initial_rotors @@ -239,20 +233,20 @@ def on_console(msg) -> None: "window.toggleMuteMaster !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.toggleMuteMaster([1])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) # WARN-02: SFX muted โ†’ attempt weapon adjust page.wait_for_function( "window.toggleMuteSfx !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.toggleMuteSfx([0])") # Mute - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) pre_change_log_count = len(logs) page.wait_for_function( "window.changeWeaponVolume !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.changeWeaponVolume([0])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) assert ( page.evaluate("document.getElementById('weapon-slider').value") == initial_weapon @@ -268,7 +262,7 @@ def on_console(msg) -> None: "window.changeRotorsVolume !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.changeRotorsVolume([0.5])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) assert ( page.evaluate("document.getElementById('rotors-slider').value") == initial_rotors @@ -283,7 +277,7 @@ def on_console(msg) -> None: "window.toggleMuteSfx !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.toggleMuteSfx([1])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) # WARN-03: Master unmuted โ†’ adjust sub-volume (Music) # Capture logs before the change to isolate new ones (good for debugging in Godot tests) @@ -292,7 +286,7 @@ def on_console(msg) -> None: "window.changeMusicVolume !== undefined", timeout=TEST_TIMEOUT ) page.evaluate("window.changeMusicVolume([0.6])") - page.wait_for_timeout(1500) + page.wait_for_timeout(TEST_TIMEOUT) # Verify the value changed (as expected, no mute constraint) assert ( diff --git a/tests/back_flow_test.py b/tests/back_flow_test.py index 33153935..0eec8b54 100644 --- a/tests/back_flow_test.py +++ b/tests/back_flow_test.py @@ -34,7 +34,7 @@ # Configuration for stability in different environments # Default to 5000ms, but allow CI to override via environment variable DEFAULT_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "30000")) -TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "5000")) +TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "10000")) def test_back_flow(page: Page) -> None: @@ -75,7 +75,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) # Verify canvas @@ -113,7 +113,7 @@ def on_console(msg) -> None: # Set log level DEBUG pre_change_log_count = len(logs) page.evaluate("window.changeLogLevel([0])") - page.wait_for_timeout(1000) + page.wait_for_timeout(TEST_TIMEOUT) new_logs = logs[pre_change_log_count:] assert any( "log level changed to: debug" in log["text"].lower() for log in new_logs @@ -208,7 +208,7 @@ def on_console(msg) -> None: # Re-enter audio page.reload() - page.wait_for_timeout(5000) + page.wait_for_timeout(DEFAULT_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=TEST_TIMEOUT) # Navigate to options menu page.wait_for_selector("#options-button", state="visible", timeout=TEST_TIMEOUT) diff --git a/tests/ci/conftest.py b/tests/ci/conftest.py new file mode 100644 index 00000000..2473dc67 --- /dev/null +++ b/tests/ci/conftest.py @@ -0,0 +1,21 @@ +# tests/ci/conftest.py + +import os +import tempfile + +import pytest + +# Dynamically locate the project root relative to this file +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +@pytest.fixture +def repo_tmp(): + """ + Creates an isolated temporary directory INSIDE the project root. + Yields a relative POSIX path (e.g. 'tmp_xyz') so WSL bash can easily digest + it without encountering Windows 'C:\\...' absolute path translation errors. + """ + with tempfile.TemporaryDirectory(dir=PROJECT_ROOT) as tmpdir: + rel_path = os.path.relpath(tmpdir, PROJECT_ROOT).replace("\\", "/") + yield rel_path diff --git a/tests/ci/test_ci_flag_injection.py b/tests/ci/test_ci_flag_injection.py new file mode 100644 index 00000000..92f8e270 --- /dev/null +++ b/tests/ci/test_ci_flag_injection.py @@ -0,0 +1,292 @@ +""" +Test suite for the CI flag injection utility. + +Ensures that 'export_presets.cfg' is correctly modified to include the 'ci' +feature flag, which is critical for bypassing production security guards +during automated browser testing. +""" + +import os +import subprocess +import sys +from pathlib import Path + +# Dynamically locate the project root to find the infrastructure script +# We need this to build an absolute path so Python can find the script +# while the test is running inside a temporary directory. +PROJECT_ROOT = Path(__file__).resolve().parents[2] +INJECT_SCRIPT_ABS = PROJECT_ROOT / ".github" / "scripts" / "inject_ci_flag.py" + + +def run_ci_injection(test_work_dir: Path) -> subprocess.CompletedProcess[str]: + """ + Executes the injection script. + Uses the absolute path to the script so it can be found regardless of cwd. + """ + # Force the child process to use UTF-8 via environment variables + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + + return subprocess.run( + [sys.executable, str(INJECT_SCRIPT_ABS)], + env=env, # Pass the forced encoding env + cwd=str(test_work_dir), + capture_output=True, + text=True, + timeout=10, + encoding="utf-8", + check=False, # Tells the linter: "I am intentionally handling exit codes manually" + ) + + +def test_inject_ci_flag_standard(repo_tmp): + """Tests injection when custom_features is empty.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + config.write_text( + '[preset.0]\ncustom_features=""\n[preset.0.options]\n', encoding="utf-8" + ) + + result = run_ci_injection(root) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + assert 'custom_features="ci"' in config.read_text(encoding="utf-8") + + +def test_inject_ci_flag_missing_key(repo_tmp): + """Tests injection when the custom_features key is entirely missing.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + # Preset exists but has no features defined + config.write_text( + '[preset.0]\nname="Web"\n[preset.0.options]\nother_setting=true', + encoding="utf-8", + ) + + result = run_ci_injection(root) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + content = config.read_text(encoding="utf-8") + + # Separated semantic assertions to avoid brittle newline (\r\n) failures + assert 'custom_features="ci"' in content + assert "[preset.0]" in content + + +def test_inject_ci_flag_existing_values(repo_tmp): + """ + Ensures existing feature flags are intentionally replaced with CI-only mode. + Note: Destructive overwrite is the intended behavior here to ensure + the CI environment is perfectly isolated from local developer flags. + """ + root = Path(repo_tmp) + config = root / "export_presets.cfg" + config.write_text( + '[preset.0]\ncustom_features="debug,test"\n[preset.0.options]\n', + encoding="utf-8", + ) + + result = run_ci_injection(root) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + content = config.read_text(encoding="utf-8") + assert 'custom_features="ci"' in content + assert '"debug,test"' not in content + + +def test_inject_ci_flag_backup_creation(repo_tmp): + """Verifies that a backup file is created and contents are perfectly preserved.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + original_content = '[preset.0]\ncustom_features=""' + config.write_text(original_content, encoding="utf-8") + + run_ci_injection(root) + + backup = root / "export_presets.cfg.bak" + assert backup.exists(), "Backup file was not created." + assert ( + backup.read_text(encoding="utf-8") == original_content + ), "Backup contents corrupted." + + +def test_inject_ci_flag_no_config_failure(repo_tmp): + """Ensures the script fails gracefully if the config file is missing.""" + root = Path(repo_tmp) + # config file NOT created + + result = run_ci_injection(root) + + assert result.returncode != 0 + combined_output = (result.stdout + result.stderr).lower() + assert "not found" in combined_output + + +def test_inject_ci_flag_idempotent(repo_tmp): + """ + Critical CI test: Running the script twice should not + corrupt or duplicate the feature flag. + """ + root = Path(repo_tmp) + config = root / "export_presets.cfg" + original_content = '[preset.0]\nname="Web"' + config.write_text(original_content, encoding="utf-8") + + # First run + first_result = run_ci_injection(root) + assert first_result.returncode == 0 + + # Second run + second_result = run_ci_injection(root) + assert second_result.returncode == 0 + + content = config.read_text(encoding="utf-8") + assert content.count('custom_features="ci"') == 1, "Flag was duplicated!" + + # Verify backup stability: The second run should NOT overwrite the backup + # with the already-mutated content from the first run. + backup = root / "export_presets.cfg.bak" + assert backup.exists(), "Backup file missing during idempotency check." + assert ( + backup.read_text(encoding="utf-8") == original_content + ), "Rollback safety destroyed: Backup was overwritten!" + + +def test_inject_ci_flag_already_exists(repo_tmp): + """Ensures the script safely handles files where 'ci' is already present.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + config.write_text('[preset.0]\ncustom_features="ci"', encoding="utf-8") + + result = run_ci_injection(root) + assert result.returncode == 0 + + content = config.read_text(encoding="utf-8") + assert content.count('custom_features="ci"') == 1 + + +def test_inject_ci_flag_multiple_presets(repo_tmp): + """Verifies that the script updates all available presets in the file.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + + multi_preset_content = ( + "[preset.0]\n" 'name="Web"\n\n' "[preset.1]\n" 'custom_features=""\n' + ) + config.write_text(multi_preset_content, encoding="utf-8") + + result = run_ci_injection(root) + assert result.returncode == 0 + + content = config.read_text(encoding="utf-8") + + # Both preset sections should contain the CI feature flag and remain intact + assert "[preset.0]" in content + assert "[preset.1]" in content + assert content.count('custom_features="ci"') == 2 + + +def test_inject_ci_flag_no_presets(repo_tmp): + """Tests a syntactically valid config file that simply lacks preset sections.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + original_content = "[general]\nfoo=bar" + config.write_text(original_content, encoding="utf-8") + + result = run_ci_injection(root) + + assert result.returncode == 0 + content = config.read_text(encoding="utf-8") + + # Ensure greedy regex didn't accidentally inject the flag anywhere + assert content == original_content + + # Verify rollback safety contract: A backup is created even on a safe no-op + assert (root / "export_presets.cfg.bak").exists(), "Backup missing on no-op" + + +def test_inject_ci_flag_malformed_config(repo_tmp): + """ + Ensures the script fails gracefully and avoids corrupting broken files. + Note: We intentionally allow a safe no-op (returncode 0) rather than a hard fail, + deferring structural validation to the Godot engine during the export step. + """ + root = Path(repo_tmp) + config = root / "export_presets.cfg" + + # Malformed section header (missing closing bracket) + malformed_content = '[preset.0\nname="Web"' + config.write_text(malformed_content, encoding="utf-8") + + result = run_ci_injection(root) + + assert result.returncode == 0 + content = config.read_text(encoding="utf-8") + + # Strictly verify no truncation or corruption occurred + assert content == malformed_content + + # Verify rollback safety contract: A backup is created even on a safe no-op + assert ( + root / "export_presets.cfg.bak" + ).exists(), "Backup missing on malformed no-op" + + +def test_inject_ci_flag_crlf_windows_endings(repo_tmp): + """Verifies the regex safely handles Windows-style CRLF line endings.""" + root = Path(repo_tmp) + config = root / "export_presets.cfg" + + # Explicitly use \r\n for line endings to simulate a Windows-authored file + crlf_content = "\r\n".join(["[preset.0]", 'name="Web"', 'custom_features=""', ""]) + + # We must use open() with newline="" to prevent Linux CI runners + # from automatically stripping the \r before writing to disk. + with open(config, "w", encoding="utf-8", newline="") as f: + f.write(crlf_content) + + result = run_ci_injection(root) + + assert result.returncode == 0 + + # Read back exactly as it is on disk, preserving the exact line endings + with open(config, "r", encoding="utf-8", newline="") as f: + content = f.read() + + # Verify the regex successfully matched the header and injected the flag + assert "[preset.0]" in content + assert 'custom_features="ci"' in content + assert content.count("custom_features=") == 1 + + +def test_inject_ci_flag_cleans_malformed_options(repo_tmp): + """ + Ensures orphaned or malformed `custom_features` inside option blocks + are safely wiped and not duplicated. + """ + root = Path(repo_tmp) + config = root / "export_presets.cfg" + + preset_with_options = ( + "[preset.0]\n" + 'custom_features=""\n\n' + "[preset.0.options]\n" + 'custom_features="foo,bar"\n' + ) + config.write_text(preset_with_options, encoding="utf-8") + + result = run_ci_injection(root) + + assert result.returncode == 0 + updated_content = config.read_text(encoding="utf-8") + + # 1. Root preset successfully gets the CI flag + assert "[preset.0]" in updated_content + assert 'custom_features="ci"' in updated_content + + # 2. Options section remains intact... + assert "[preset.0.options]" in updated_content + + # 3. ...but the invalid/orphaned custom_features is successfully scrubbed! + assert 'custom_features="foo,bar"' not in updated_content + assert updated_content.count("custom_features=") == 1 diff --git a/tests/ci/test_salt_injection.py b/tests/ci/test_salt_injection.py index dafc66fe..c5396a91 100644 --- a/tests/ci/test_salt_injection.py +++ b/tests/ci/test_salt_injection.py @@ -1,146 +1,234 @@ +""" +Test suite for the CI/CD salt injection pipeline. + +Validates that the master bash script correctly replaces placeholder strings +in GDScript files with various complex secrets, ensuring that escape sequences +and special sed characters do not break the final game code. +""" + import os import subprocess -import sys - -# The exact AWK script from deploy_to_itch.yml -AWK_SCRIPT = """ -BEGIN { - salt = ENVIRON["SALT"] - in_game = 0 - salt_written = 0 - saw_game_section = 0 -} -{ - if ($0 ~ /^\\[game\\]/) { - in_game = 1 - saw_game_section = 1 - print - next - } else if ($0 ~ /^\\[/ && $0 !~ /^\\[game\\]/) { - if (in_game && !salt_written) { - print "security/save_salt=\\"" salt "\\"" - salt_written = 1 - } - in_game = 0 - print - next - } - if (in_game && $0 ~ /^[[:space:]]*security\\/save_salt[[:space:]]*=/) { - if (!salt_written) { - print "security/save_salt=\\"" salt "\\"" - salt_written = 1 - } - next - } - print -} -END { - if (in_game && !salt_written) { - print "security/save_salt=\\"" salt "\\"" - salt_written = 1 - } - if (!saw_game_section) { - if (NR > 0) { print "" } - print "[game]" - print "security/save_salt=\\"" salt "\\"" - } -} -""" +from pathlib import Path +import pytest -def run_awk_injection(file_path): - # 1. Define a nasty test secret with quotes and slashes - RAW_SECRET = 'my"nasty\\salt123' +# Dynamically locate the project root +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +INJECT_SCRIPT_REL = ".github/scripts/inject_salt.sh" - # Emulate the sed command: replace \ with \\, and " with \" - escaped_salt = RAW_SECRET.replace("\\", "\\\\").replace('"', '\\"') - # Load into environment variables for awk +def run_injection(file_path, raw_secret): + """ + Executes the single-source-of-truth bash script using relative paths. + Returns the CompletedProcess object for assertion checking. + """ env = os.environ.copy() - env["SALT"] = escaped_salt + env["PRODUCTION_SALT"] = raw_secret + + if "WSLENV" in env: + env["WSLENV"] += ":PRODUCTION_SALT/u" + else: + env["WSLENV"] = "PRODUCTION_SALT/u" + + script_abs_path = os.path.join(PROJECT_ROOT, INJECT_SCRIPT_REL) + assert os.path.exists( + script_abs_path + ), f"Master inject script not found at {script_abs_path}" + + return subprocess.run( + ["bash", INJECT_SCRIPT_REL, str(file_path)], + env=env, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + check=False, # Tells the linter: "I am intentionally handling exit codes manually" + ) + + +@pytest.mark.parametrize( + "scenario, raw_secret, expected_salt", + [ + ("standard", 'T3st_S@lt!_2026#"\\', 'T3st_S@lt!_2026#\\"\\\\'), + ("sed_special", "My|Secret&Salt", "My|Secret&Salt"), + ("regex_tokens", r"\1 \2 $HOME", r"\\1 \\2 $HOME"), + ("utf8_unicode", "ะฟะฐั€ะพะปัŒ_ๆ—ฅๆœฌ่ชž_๐Ÿ”’", "ะฟะฐั€ะพะปัŒ_ๆ—ฅๆœฌ่ชž_๐Ÿ”’"), + ( + "forward_slash", + "path/to/my/secret", + "path/to/my/secret", + ), # Ensures forward slashes don't break sed + ], +) +def test_injection_values(repo_tmp, scenario, raw_secret, expected_salt): + """ + Parametrized test covering standard strings, bash/sed delimiters, + backreferences, UTF-8 locale handling, and path-like slashes. + """ + dummy_rel = f"{repo_tmp}/dummy_{scenario}.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + + dummy_abs.write_text( + 'func _get_encryption_key() -> String:\n\tvar salt: String = "CI_INJECT_SALT_HERE"\n\treturn salt\n', + encoding="utf-8", + ) + + result = run_injection(dummy_rel, raw_secret) + + assert ( + result.returncode == 0 + ), f"Injection failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}" + + content = dummy_abs.read_text(encoding="utf-8") + assert f'var salt: String = "{expected_salt}"' in content + + # Verifies sed did not leave behind macOS/Linux .bak or temp file artifacts + files_in_dir = list(dummy_abs.parent.iterdir()) + unexpected = [f for f in files_in_dir if f.name != dummy_abs.name] + assert ( + not unexpected + ), f"Artifact pollution detected. Unexpected files found: {unexpected}" + + +def test_injection_multiple_placeholders(repo_tmp): + """Ensures global replacement updates all occurrences, leaving no partial leftovers.""" + dummy_rel = f"{repo_tmp}/dummy_multi.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + + dummy_abs.write_text( + "extends Node\n" + 'var security = {"save_salt": "CI_INJECT_SALT_HERE"}\n' + 'var another = {"save_salt": "CI_INJECT_SALT_HERE"}\n', + encoding="utf-8", + ) + + result = run_injection(dummy_rel, "multi-placeholder-salt") + + assert result.returncode == 0 + content = dummy_abs.read_text(encoding="utf-8") + assert "CI_INJECT_SALT_HERE" not in content + assert content.count("multi-placeholder-salt") == 2 + + +def test_injection_missing_placeholder(repo_tmp): + """A missing placeholder is a safe no-op. The file must remain untouched.""" + dummy_rel = f"{repo_tmp}/dummy_missing.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + original_content = 'extends Node\nvar security = {"save_salt": "unchanged"}\n' + + dummy_abs.write_text(original_content, encoding="utf-8") + + result = run_injection(dummy_rel, "missing-placeholder-salt") + + assert result.returncode == 0 + assert dummy_abs.read_text(encoding="utf-8") == original_content + + +def test_injection_empty_secret(repo_tmp): + """ + Ensures the bash script guard catches an empty environment variable and aborts. + """ + dummy_rel = f"{repo_tmp}/dummy_empty.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + original_content = 'var salt = "CI_INJECT_SALT_HERE"\n' + dummy_abs.write_text(original_content, encoding="utf-8") + result = run_injection(dummy_rel, "") + + # Non-zero return code indicates the script aborted as expected + assert result.returncode != 0 + + # Some error output should be produced, but don't depend on exact wording + assert (result.stdout + result.stderr).strip() != "" + + # Verify no partial corruption occurred + assert dummy_abs.read_text(encoding="utf-8") == original_content + + +def test_injection_filename_with_spaces(repo_tmp): + """Verifies bash variable quoting robustness against argument splitting.""" + dummy_rel = f"{repo_tmp}/dummy globals spaces.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + + dummy_abs.write_text('var salt = "CI_INJECT_SALT_HERE"\n', encoding="utf-8") + + result = run_injection(dummy_rel, "space-test-secret") + + assert result.returncode == 0 + content = dummy_abs.read_text(encoding="utf-8") + assert "space-test-secret" in content + + +def test_injection_non_existent_file(): + """Ensures script fails fast with a deterministic error on bad paths.""" + result = run_injection("this_file_does_not_exist.gd", "non-existent-file-salt") + assert result.returncode != 0 + assert "does not exist" in result.stdout + + +def test_injection_multiline_secret(repo_tmp): + """Validates that multiline secrets safely fail instead of silently corrupting.""" + dummy_rel = f"{repo_tmp}/dummy_multiline.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + original_content = 'var salt = "CI_INJECT_SALT_HERE"\n' + + dummy_abs.write_text(original_content, encoding="utf-8") + + result = run_injection(dummy_rel, "line1\nline2") + + # Platform agnostic check: It just needs to fail deterministically + assert result.returncode != 0 + + # Ensure an error is reported without coupling to a specific tool name + assert result.stderr.strip() != "" + + # Verify the file was not accidentally mangled before failure + assert dummy_abs.read_text(encoding="utf-8") == original_content + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file permission mechanics required") +def test_injection_readonly_file(repo_tmp): + """ + Catches CI/container filesystem edge cases. + Secures both the file AND the parent directory to prevent + Linux 'sed -i' from bypassing read-only file locks via directory rename. + """ + dummy_rel = f"{repo_tmp}/dummy_readonly.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + original_content = 'var salt = "CI_INJECT_SALT_HERE"\n' + dummy_abs.write_text(original_content, encoding="utf-8") + + # Lock file and directory + dummy_abs.chmod(0o444) + dummy_abs.parent.chmod(0o555) try: - # Run the awk script via subprocess - with open(f"{file_path}.tmp", "w") as temp_file: - subprocess.run( - ["awk", AWK_SCRIPT, file_path], - env=env, - stdout=temp_file, - check=True, - text=True, - ) - # Replace original file with the modified tmp file - os.replace(f"{file_path}.tmp", file_path) - except FileNotFoundError: - print("โŒ ERROR: 'awk' command not found.") - print( - "Since the GitHub action uses Linux tools, 'awk' must be accessible to Windows." - ) - print( - "Run this Python script from your VS Code Git Bash terminal instead of PowerShell." - ) - sys.exit(1) - - -def test_injection(): - dummy_file = "dummy.godot" - expected_salt_line = 'security/save_salt="my\\"nasty\\\\salt123"' - - print("Running Salt Injection Tests in Python...") - print("-" * 40) - - # TEST 1: No [game] section exists - with open(dummy_file, "w") as f: - f.write('[application]\nname="Test"\n') - - run_awk_injection(dummy_file) - - with open(dummy_file, "r") as f: - content = f.read() - if expected_salt_line in content and "[game]" in content: - print("โœ… TEST 1 PASS: Created [game] section and injected salt.") - else: - print(f"โŒ TEST 1 FAIL\n{content}") - sys.exit(1) - - # TEST 2: [game] section exists, followed by another section - with open(dummy_file, "w") as f: - f.write('[application]\nname="Test"\n[game]\nsome_setting=1\n[audio]\nbus=1\n') - - run_awk_injection(dummy_file) - - with open(dummy_file, "r") as f: - content = f.read() - # Check if salt is injected before [audio] - if expected_salt_line in content and content.find( - expected_salt_line - ) < content.find("[audio]"): - print("โœ… TEST 2 PASS: Injected salt inside existing [game] section.") - else: - print(f"โŒ TEST 2 FAIL\n{content}") - sys.exit(1) - - # TEST 3: [game] section exists and already has an old salt (overwrite) - with open(dummy_file, "w") as f: - f.write('[game]\nsecurity/save_salt="old_salt"\nother=2\n') - - run_awk_injection(dummy_file) - - with open(dummy_file, "r") as f: - content = f.read() - if expected_salt_line in content and "old_salt" not in content: - print("โœ… TEST 3 PASS: Overwrote existing salt correctly.") - else: - print(f"โŒ TEST 3 FAIL\n{content}") - sys.exit(1) - - print("-" * 40) - print("๐ŸŽ‰ ALL INJECTION TESTS PASSED!") - - # Cleanup - if os.path.exists(dummy_file): - os.remove(dummy_file) - - -if __name__ == "__main__": - test_injection() + result = run_injection(dummy_rel, "readonly-secret") + + assert result.returncode != 0 + assert dummy_abs.read_text(encoding="utf-8") == original_content + finally: + # MUST restore write permissions, otherwise pytest temporary directory cleanup will crash + dummy_abs.parent.chmod(0o777) + dummy_abs.chmod(0o666) + + +def test_idempotency(repo_tmp): + """Running the injection twice should not cause corruption or double-escaping.""" + dummy_rel = f"{repo_tmp}/dummy_idempotent.gd" + dummy_abs = Path(PROJECT_ROOT) / dummy_rel + + dummy_abs.write_text('var salt = "CI_INJECT_SALT_HERE"\n', encoding="utf-8") + + # First run must succeed + first = run_injection(dummy_rel, "idempotent-secret") + assert first.returncode == 0, "Initial injection failed" + + # Second run must also succeed and leave content uncorrupted + second = run_injection(dummy_rel, "idempotent-secret") + + assert second.returncode == 0 + content = dummy_abs.read_text(encoding="utf-8") + + # Strict matching to guarantee NO duplicate injection or mangled formatting + assert content == 'var salt = "idempotent-secret"\n' diff --git a/tests/difficulty_flow_test.py b/tests/difficulty_flow_test.py index 695b1838..9b54dbe0 100644 --- a/tests/difficulty_flow_test.py +++ b/tests/difficulty_flow_test.py @@ -84,7 +84,7 @@ def on_console(msg: Any) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) # Wait for Godot engine init (ensures 'godot' object is defined) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) @@ -236,7 +236,7 @@ def on_console(msg: Any) -> None: # Set difficulty to 2.0 again page.evaluate("window.changeDifficulty([2.0])") - page.wait_for_timeout(2500) + page.wait_for_timeout(TEST_TIMEOUT) # Back to Main menu pre_change_log_count = len(logs) diff --git a/tests/load_main_menu_test.py b/tests/load_main_menu_test.py index 38146d2f..b3a5c67e 100644 --- a/tests/load_main_menu_test.py +++ b/tests/load_main_menu_test.py @@ -82,7 +82,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) # Wait for Godot engine init (ensures 'godot' object is defined) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) diff --git a/tests/navigation_to_audio_test.py b/tests/navigation_to_audio_test.py index 7252e7ea..a84426d9 100644 --- a/tests/navigation_to_audio_test.py +++ b/tests/navigation_to_audio_test.py @@ -75,7 +75,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) # Verify canvas diff --git a/tests/no_error_logs_test.py b/tests/no_error_logs_test.py index 9e730c25..a452dd4b 100644 --- a/tests/no_error_logs_test.py +++ b/tests/no_error_logs_test.py @@ -69,7 +69,7 @@ def on_page_error(exc) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(15000) + page.wait_for_timeout(DEFAULT_TIMEOUT) # Wait for the custom Godot initialization flag page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) diff --git a/tests/reset_audio_flow_test.py b/tests/reset_audio_flow_test.py index e0109856..97c88896 100644 --- a/tests/reset_audio_flow_test.py +++ b/tests/reset_audio_flow_test.py @@ -75,7 +75,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) # Verify canvas @@ -115,7 +115,7 @@ def on_console(msg) -> None: # Set log level DEBUG pre_change_log_count = len(logs) page.evaluate("window.changeLogLevel([0])") - page.wait_for_timeout(1000) + page.wait_for_timeout(TEST_TIMEOUT) new_logs = logs[pre_change_log_count:] assert any( "log level changed to: debug" in log["text"].lower() for log in new_logs @@ -380,7 +380,7 @@ def on_console(msg) -> None: # Reload and validate persisted defaults for all audio controls page.reload() - page.wait_for_timeout(TEST_TIMEOUT) + page.wait_for_timeout(DEFAULT_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=TEST_TIMEOUT) page.wait_for_selector("#options-button", state="visible", timeout=TEST_TIMEOUT) page.wait_for_function( diff --git a/tests/validate_clean_load_test.py b/tests/validate_clean_load_test.py index 3bc41cfa..556a5542 100644 --- a/tests/validate_clean_load_test.py +++ b/tests/validate_clean_load_test.py @@ -53,7 +53,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1.5. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) # 2. Wait for the engine's ready signal page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) diff --git a/tests/volume_sliders_mutes_test.py b/tests/volume_sliders_mutes_test.py index 91f7c24d..b1236b08 100644 --- a/tests/volume_sliders_mutes_test.py +++ b/tests/volume_sliders_mutes_test.py @@ -75,7 +75,7 @@ def on_console(msg) -> None: timeout=DEFAULT_TIMEOUT, ) # 1. Wait for the engine to actually start the splash scene - page.wait_for_timeout(5000) + page.wait_for_timeout(TEST_TIMEOUT) page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) # Verify canvas diff --git a/workspace/run_browser_tests.sh b/workspace/run_browser_tests.sh index e58f33b0..48bd6796 100644 --- a/workspace/run_browser_tests.sh +++ b/workspace/run_browser_tests.sh @@ -5,7 +5,7 @@ PROJECT_DIR="/project" EXPORT_DIR="$PROJECT_DIR/export/web_thread_off" SERVER_PORT=8080 -PW_TIMEOUT=10000 +PW_TIMEOUT=30000 # Function to check if a step failed check_exit() { @@ -15,46 +15,74 @@ check_exit() { fi } -# Browser Functional Tests -echo "Exporting Godot Project to Web..." -mkdir -p $EXPORT_DIR +# 1. Inject a dummy salt (Pipeline consistency) +echo "โš™๏ธ Injecting dummy salt for Playwright tests..." +PRODUCTION_SALT="playwright_dummy_salt_123" bash .github/scripts/inject_salt.sh "scripts/core/globals.gd" +check_exit "Salt Injection" -# Simulate firebelley/godot-export action: Run Godot export to HTML5 -# godot --headless --path $PROJECT_DIR --export-release "Web_thread_off" $EXPORT_DIR/index.html -godot --headless --path $PROJECT_DIR --export-release "Web_thread_off" $EXPORT_DIR/index.html -check_exit "Godot Web Export" +# 2. FORCE the "ci" feature flag into export_presets.cfg +# Godot 4 ignores CLI feature flags, so we must inject it into the preset directly +echo "โš™๏ธ Injecting 'ci' feature flag into export_presets.cfg..." +python3 .github/scripts/inject_ci_flag.py +check_exit "CI Flag Injection" -# Start web server in background -python3 -m http.server $SERVER_PORT --directory $EXPORT_DIR & +# 3. Export the Web build for functional testing +echo "๐ŸŽฎ Exporting Godot Project to Web (Web_thread_off)..." +mkdir -p "$EXPORT_DIR" +godot --headless --path "$PROJECT_DIR" --export-release "Web_thread_off" "$EXPORT_DIR/index.html" +check_exit "Godot Export" + +# 4. Clean up the repository +# We strictly revert globals.gd and export_presets.cfg to keep your repo pristine +echo "๐Ÿงน Restoring files to pristine state..." +git restore export_presets.cfg +git restore scripts/core/globals.gd + +# 5. Start a security-isolated web server +# Provides the COOP and COEP headers absolutely required by Godot 4 Web exports +echo "๐Ÿš€ Starting security-isolated server on port $SERVER_PORT..." +python3 -c " +import http.server, socketserver, os +class MyHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cross-Origin-Opener-Policy', 'same-origin') + self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + super().end_headers() +socketserver.TCPServer.allow_reuse_address = True +with socketserver.TCPServer(('', $SERVER_PORT), MyHandler) as httpd: + os.chdir('$EXPORT_DIR') + httpd.serve_forever() +" & SERVER_PID=$! -# Wait for server to be ready -for i in {1..20}; do - if curl -f http://localhost:$SERVER_PORT/index.html >/dev/null 2>&1; then - echo "Web server ready" - break - fi +echo "Waiting for server to respond..." +max_retries=20 +count=0 +while ! curl -s http://localhost:$SERVER_PORT/index.html > /dev/null; do sleep 1 + count=$((count + 1)) + if [ $count -eq $max_retries ]; then + echo "โŒ Server failed to start" + kill $SERVER_PID + exit 1 + fi done -if [ $i -eq 20 ]; then - echo "Web server failed to start" - kill $SERVER_PID - exit 1 -fi +echo "โœ… Server ready" -# Run Playwright tests -echo "Running Playwright Browser Tests..." -mkdir -p $PROJECT_DIR/artifacts # No chown +# 6. Run Playwright tests +echo "๐Ÿงช Running Playwright Browser Tests..." +mkdir -p "$PROJECT_DIR/artifacts" source /opt/venv/bin/activate -xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" pytest tests/ -v --timeout=$PW_TIMEOUT --ignore=tests/refactor --capture=no --html=$PROJECT_DIR/report.html --self-contained-html --junitxml=$PROJECT_DIR/report.xml +xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" pytest tests/ -v --timeout=$PW_TIMEOUT --ignore=tests/refactor --ignore=tests/ci --capture=no --html="$PROJECT_DIR/report.html" --self-contained-html --junitxml="$PROJECT_DIR/report.xml" check_exit "Playwright Tests" # Generate test report summary -if [ -f $PROJECT_DIR/report.xml ]; then - total=$(xmllint --xpath 'count(//testcase)' $PROJECT_DIR/report.xml) - failures=$(xmllint --xpath 'count(//testcase/failure)' $PROJECT_DIR/report.xml) - errors=$(xmllint --xpath 'count(//testcase/error)' $PROJECT_DIR/report.xml) - skipped=$(xmllint --xpath 'count(//testcase/skipped)' $PROJECT_DIR/report.xml) +if [ -f "$PROJECT_DIR/report.xml" ]; then + total=$(xmllint --xpath 'count(//testcase)' "$PROJECT_DIR/report.xml") + failures=$(xmllint --xpath 'count(//testcase/failure)' "$PROJECT_DIR/report.xml") + errors=$(xmllint --xpath 'count(//testcase/error)' "$PROJECT_DIR/report.xml") + skipped=$(xmllint --xpath 'count(//testcase/skipped)' "$PROJECT_DIR/report.xml") passed=$((total - failures - errors - skipped)) echo "Test Report Summary:" echo "- Total tests: $total" @@ -66,13 +94,5 @@ else echo "No report.xml foundโ€”tests may not have run." fi -# Cleanup: Stop server +# Cleanup kill $SERVER_PID - -# Simulate artifact uploads (copy to host via mounted volume) -mkdir -p $PROJECT_DIR/artifacts -cp $PROJECT_DIR/report.xml $PROJECT_DIR/artifacts/ || true -cp main_menu.png $PROJECT_DIR/artifacts/ || true # If screenshot exists -cp -r $PROJECT_DIR/reports $PROJECT_DIR/artifacts/gdunit-reports || true - -echo "Pipeline completed successfully!" diff --git a/workspace/test_injection.sh b/workspace/test_injection.sh new file mode 100644 index 00000000..87386ba2 --- /dev/null +++ b/workspace/test_injection.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# test_injection.sh + +cleanup() { + if [ -f "globals.gd.backup" ]; then + echo "๐Ÿงน Cleaning up: Restoring globals.gd from backup..." + mv -f "globals.gd.backup" scripts/core/globals.gd + fi + if [ -f "project.godot.backup" ]; then + echo "๐Ÿงน Cleaning up: Restoring project.godot from backup..." + mv -f "project.godot.backup" project.godot + fi + if [ -f "export_presets.cfg.backup" ]; then + echo "๐Ÿงน Cleaning up: Restoring export_presets.cfg from backup..." + mv -f "export_presets.cfg.backup" export_presets.cfg + fi + # Clean up the internal artifact created by the Python injection script + rm -f export_presets.cfg.bak +} +trap cleanup EXIT INT TERM + +GODOT_CMD="godot" +RAW_SECRET='T3st_S@lt!_2026#"\' +export PRODUCTION_SALT="$RAW_SECRET" + +# Source shared CI utilities +source .github/scripts/ci_utils.sh + +echo "==========================================" +echo " Checking System Dependencies" +echo "==========================================" +for cmd in $GODOT_CMD python3 bash sed; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "โŒ ERROR: Required command '$cmd' is not installed or not in your PATH." + exit 1 + fi +done + +echo "==========================================" +echo " Starting Local CI/CD Simulation" +echo "==========================================" + +if [ ! -f "scripts/core/globals.gd" ] || [ ! -f "project.godot" ] || [ ! -f "export_presets.cfg" ]; then + echo "โŒ ERROR: Required project files not found!" + exit 1 +fi + +cp scripts/core/globals.gd globals.gd.backup +cp project.godot project.godot.backup +cp export_presets.cfg export_presets.cfg.backup + +echo "๐Ÿ—‘๏ธ Wiping previous web export files..." +rm -rf export/web/* +mkdir -p export/web + +# Disable editor plugins (GUT) to prevent headless crashes +disable_editor_plugins "project.godot" + +# Call the Single Source of Truth script +bash ./.github/scripts/inject_salt.sh "scripts/core/globals.gd" || { + echo "โŒ ERROR: Master injection script failed." + exit 1 +} + +echo "โš™๏ธ Injecting 'ci' feature flag into export_presets.cfg..." +python3 .github/scripts/inject_ci_flag.py || { + echo "โŒ ERROR: CI flag injection script failed." + exit 1 +} + +echo "๐ŸŽฎ Exporting Godot project (Web preset)..." + +$GODOT_CMD --verbose --headless --export-release "Web" export/web/index.html > export_log.txt 2>&1 & +GODOT_PID=$! + +SECONDS=0 +SPINNER="-\|/" +i=0 +while kill -0 $GODOT_PID 2>/dev/null; do + i=$(( (i+1) % 4 )) + printf "\rโš™๏ธ Godot is working... %s (Elapsed Time: %d seconds)" "${SPINNER:$i:1}" "$SECONDS" + sleep 1 +done + +printf "\rโœ… Export process finished! (Total time: %d seconds) \n" "$SECONDS" + +wait $GODOT_PID +if [ $? -ne 0 ]; then + echo "โŒ FATAL: Godot engine crashed during export." + echo "๐Ÿ“„ Printing the last 20 lines of the crash log:" + tail -n 20 export_log.txt + exit 1 +fi + +if [ ! -f "export/web/index.pck" ]; then + echo "โŒ Export failed. index.pck not found." + exit 1 +fi + +if [ -f "./.github/scripts/patch_index_js.sh" ]; then + bash ./.github/scripts/patch_index_js.sh "export/web" || { + echo "โŒ ERROR: patch_index_js.sh failed." + exit 1 + } +fi + +echo "โœ… Build pipeline completed successfully." + +echo "==========================================" +echo " Starting Local Game Server" +echo "==========================================" +cat << 'EOF' > export/web/serve.py +import http.server +PORT = 8080 +class Handler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + super().end_headers() +if __name__ == '__main__': + with http.server.ThreadingHTTPServer(("", PORT), Handler) as httpd: + print(f"๐Ÿš€ Game server running! Open http://localhost:{PORT} in your browser.") + httpd.serve_forever() +EOF + +cd export/web || { + echo "โŒ ERROR: Failed to change to export/web directory!" + exit 1 +} +python3 serve.py