diff --git a/.github/workflows/browser_test.yml b/.github/workflows/browser_test.yml index 20fa46b7..63aa0c52 100644 --- a/.github/workflows/browser_test.yml +++ b/.github/workflows/browser_test.yml @@ -150,7 +150,7 @@ jobs: - name: "Upload LCOV Artifact" if: always() - uses: "actions/upload-artifact@v6" + uses: "actions/upload-artifact@v7" with: name: lcov-report path: "./coverage/lcov/lcov.info" @@ -176,14 +176,14 @@ jobs: - name: "Upload Test Report Artifact" if: always() - uses: "actions/upload-artifact@v6" + uses: "actions/upload-artifact@v7" with: name: test-report path: junit.xml - name: "Upload Screenshot and Coverage Artifacts" if: always() - uses: "actions/upload-artifact@v6" + uses: "actions/upload-artifact@v7" with: name: test-screenshots path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 07a1ea12..2f8896c0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -62,7 +62,7 @@ jobs: run: | bash ./.github/scripts/patch_index_js.sh "export/web" - name: "Initialize CodeQL" - uses: "github/codeql-action/init@v4.32.3" + uses: "github/codeql-action/init@v4.32.4" with: # yamllint disable rule:quoted-strings languages: ${{ matrix.language }} @@ -74,10 +74,10 @@ jobs: debug: "false" - name: "Autobuild (optional for JS but included for completeness)" - uses: "github/codeql-action/autobuild@v4.32.3" + uses: "github/codeql-action/autobuild@v4.32.4" - name: "Perform CodeQL Analysis" - uses: "github/codeql-action/analyze@v4.32.3" + uses: "github/codeql-action/analyze@v4.32.4" - name: "Post-scan summary (optional)" if: "always()" # Run even if previous steps fail diff --git a/.github/workflows/gdunit4_tests.yml b/.github/workflows/gdunit4_tests.yml index 7fc8fd34..f782f413 100644 --- a/.github/workflows/gdunit4_tests.yml +++ b/.github/workflows/gdunit4_tests.yml @@ -51,7 +51,7 @@ jobs: ls -la ${{ steps.find_report.outputs.latest_report }}/ - name: "Upload Test Reports Artifacts" if: always() - uses: "actions/upload-artifact@v6" + uses: "actions/upload-artifact@v7" with: name: gdunit-reports path: reports/** diff --git a/.github/workflows/gut_tests.yml b/.github/workflows/gut_tests.yml index ec662dc0..8bd40ca2 100644 --- a/.github/workflows/gut_tests.yml +++ b/.github/workflows/gut_tests.yml @@ -53,7 +53,7 @@ jobs: ls -la ${{ steps.find_report.outputs.latest_report }}/ - name: "Upload Test Reports Artifacts" if: always() - uses: "actions/upload-artifact@v6" + uses: "actions/upload-artifact@v7" with: name: gut-reports path: gut-reports/** diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index 11b8f8de..ccf0d067 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -43,7 +43,7 @@ jobs: continue-on-error: true - name: "Upload Snyk Code SARIF to GitHub" - uses: "github/codeql-action/upload-sarif@ef618feace3c4838ae42b239ab86e8fb46437508" + uses: "github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281" if: "always() && hashFiles('snyk-code.sarif') != ''" with: sarif_file: "snyk-code.sarif" @@ -54,7 +54,7 @@ jobs: continue-on-error: true - name: "Upload Snyk Open Source SARIF to GitHub" - uses: "github/codeql-action/upload-sarif@ef618feace3c4838ae42b239ab86e8fb46437508" + uses: "github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281" if: "always() && hashFiles('snyk-os.sarif') != ''" with: sarif_file: "snyk-os.sarif" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 6de2c574..f1ecb9f4 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -21,7 +21,7 @@ jobs: persist-credentials: false - name: "Run Trivy FS Scan" uses: "aquasecurity/trivy-action@master" - # uses: "aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284" # Pinned to SHA for v0.34.0 + # uses: "aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284" env: # Suppress version check notification TRIVY_SKIP_VERSION_CHECK: 'true' with: @@ -36,7 +36,7 @@ jobs: ignore-unfixed: true # Ignore vulns without fixes available - name: "Upload Trivy scan results to GitHub Security tab" - uses: "github/codeql-action/upload-sarif@ef618feace3c4838ae42b239ab86e8fb46437508" # Pinned to SHA for v4.32.2 + uses: "github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281" # Pinned to SHA for v4.32.2 if: always() # Upload even if scan fails with: sarif_file: 'trivy-results.sarif' diff --git a/settings/default_settings.tres b/config_resources/default_settings.tres similarity index 100% rename from settings/default_settings.tres rename to config_resources/default_settings.tres diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index d4ce78c3..80a35c72 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -21,11 +21,6 @@ extends Resource # game_settings_resource.gd @export var difficulty: float = 1.0: set(value): - if value < 0.5 or value > 2.0: - Globals.log_message( - "Invalid difficulty loaded (" + str(value) + ") - clamping to valid range.", - Globals.LogLevel.WARNING - ) _difficulty = clamp(value, 0.5, 2.0) get: return _difficulty diff --git a/scripts/globals.gd b/scripts/globals.gd index 38b3aa31..c8396aca 100644 --- a/scripts/globals.gd +++ b/scripts/globals.gd @@ -19,8 +19,7 @@ enum LogLevel { DEBUG, INFO, WARNING, ERROR, NONE = 4 } # @export var difficulty: float = 1.0 # Multiplier: 1.0=Normal, <1=Easy, >1=Hard # Add the resource reference here -@export var settings: GameSettingsResource = preload("res://settings/default_settings.tres") - +var settings: GameSettingsResource # In globals.gd (add after @export vars) var options_instance: CanvasLayer = null # var hidden_menu: Node = null @@ -37,6 +36,15 @@ var current_input_device: String = "keyboard" # "keyboard" or "gamepad" func _ready() -> void: + # Load the resource here instead of preloading at the top + settings = load("res://config_resources/default_settings.tres") as GameSettingsResource + if settings == null: + # Use push_error since Globals logging might not be ready + push_error("CRITICAL: 'GameSettingsResource' failed to load at path.") + # Fallback to in-memory defaults so Globals remains operational + settings = GameSettingsResource.new() + settings.current_log_level = LogLevel.WARNING + if Engine.is_editor_hint() or settings.enable_debug_logging: settings.current_log_level = LogLevel.DEBUG log_message("Log level set to: " + LogLevel.keys()[settings.current_log_level], LogLevel.DEBUG) diff --git a/scripts/input_remap_button.gd b/scripts/input_remap_button.gd index 3b67ad9b..a3eeec68 100644 --- a/scripts/input_remap_button.gd +++ b/scripts/input_remap_button.gd @@ -116,12 +116,19 @@ func _ready() -> void: func _on_pressed() -> void: listening = button_pressed if listening: - # FIXED: Check the actual current_device to return the correct prompt - text = ( - Globals.settings.remap_prompt_keyboard - if current_device == DeviceType.KEYBOARD - else Globals.settings.remap_prompt_gamepad - ) + # Safely check if Globals and settings are ready + if is_instance_valid(Globals) and Globals.settings: + # FIXED: Check the actual current_device to return the correct prompt + text = ( + Globals.settings.remap_prompt_keyboard + if current_device == DeviceType.KEYBOARD + else Globals.settings.remap_prompt_gamepad + ) + else: + # Globals.log_message("'Globals.settings' resource is NULL", Globals.LogLevel.ERROR) + push_error("ERROR: 'Globals.settings' resource is NULL") + # Fallback prompt so the UI still reflects that we are listening + text = ("Press a key" if current_device == DeviceType.KEYBOARD else "Press a button") else: update_button_text() diff --git a/scripts/key_mapping.gd b/scripts/key_mapping.gd index b7d7100b..91270277 100644 --- a/scripts/key_mapping.gd +++ b/scripts/key_mapping.gd @@ -179,6 +179,7 @@ func _on_reset_pressed() -> void: # Function resets only the selected device type (keyboard or gamepad) var device_type: String = "keyboard" if keyboard.button_pressed else "gamepad" Settings.reset_to_defaults(device_type) + # You must update the UI buttons so they show the new defaults! update_all_remap_buttons() Globals.log_message("Resetting " + device_type + " controls.", Globals.LogLevel.DEBUG) # NEW: Clear critical warning flag when player fixes unbound (once-per-session) diff --git a/scripts/settings.gd b/scripts/settings.gd index 65ec3dd7..5e8f916e 100644 --- a/scripts/settings.gd +++ b/scripts/settings.gd @@ -372,7 +372,9 @@ func _remove_event_from_conflicts(event: InputEvent, conflicts: Array[String]) - ## Deserializes a string back to InputEvent. ## Handles "key:code", "joybtn:index:device", "joyaxis:axis:value:device". func deserialize_event(serialized: String) -> InputEvent: - # 1. Reject plain integers or empty strings immediately + var event_to_return: InputEvent = null + + # 1. Reject invalid prefixes immediately if not ( serialized.begins_with("key:") or serialized.begins_with("joybtn:") @@ -386,25 +388,30 @@ func deserialize_event(serialized: String) -> InputEvent: match parts[0]: "key": - if parts[1].is_valid_int(): + if parts.size() >= 2 and parts[1].is_valid_int(): + var code := parts[1].to_int() + + # OPINION: Explicitly reject 0 to prevent "silent drops" + # as suggested by Sourcery. + if code == 0: + Globals.log_message( + "Ignoring key event with keycode 0", Globals.LogLevel.WARNING + ) + return null + var ev := InputEventKey.new() - ev.physical_keycode = parts[1].to_int() - # Logic for combinations (Shift + Tab etc) - if "shift" in parts: - ev.shift_pressed = true - if "ctrl" in parts: - ev.ctrl_pressed = true - if "alt" in parts: # NEW: Restore Alt - ev.alt_pressed = true - if "meta" in parts: # NEW: Restore Meta - ev.meta_pressed = true - return ev + ev.physical_keycode = code + ev.shift_pressed = "shift" in parts + ev.ctrl_pressed = "ctrl" in parts + ev.alt_pressed = "alt" in parts + ev.meta_pressed = "meta" in parts + event_to_return = ev "joybtn": if parts.size() == 3 and parts[1].is_valid_int() and parts[2].is_valid_int(): var ev := InputEventJoypadButton.new() ev.button_index = parts[1].to_int() ev.device = parts[2].to_int() - return ev + event_to_return = ev "joyaxis": if ( parts.size() == 4 @@ -416,9 +423,9 @@ func deserialize_event(serialized: String) -> InputEvent: ev.axis = parts[1].to_int() ev.axis_value = parts[2].to_float() ev.device = parts[3].to_int() - return ev + event_to_return = ev - return null + return event_to_return ## Deserializes a string to an InputEvent and adds it to the specified action. diff --git a/test/gdunit4/test_globals.gd b/test/gdunit4/test_globals.gd index cd2c6069..f6f3b256 100644 --- a/test/gdunit4/test_globals.gd +++ b/test/gdunit4/test_globals.gd @@ -10,11 +10,15 @@ extends GdUnitTestSuite var globals: Node var test_path: String = "user://test_globals.cfg" # Temp for isolation + func before_test() -> void: - ## Per-test setup: Instantiate globals. - ## - ## :rtype: void + # Instantiate the script globals = auto_free(load("res://scripts/globals.gd").new()) + + # FIX: Manually initialize the settings resource + # because _ready() hasn't run yet. + globals.settings = GameSettingsResource.new() + func after_test() -> void: ## Per-test cleanup: Remove test file. @@ -23,6 +27,7 @@ func after_test() -> void: if FileAccess.file_exists(test_path): DirAccess.remove_absolute(test_path) + func test_save_settings_preserves_other_sections() -> void: ## Tests settings save preserves unrelated sections (e.g., "audio"). ## @@ -42,6 +47,7 @@ func test_save_settings_preserves_other_sections() -> void: assert_float(config.get_value("Settings", "difficulty", 1.0)).is_equal(1.2) assert_float(config.get_value("audio", "master_volume", 1.0)).is_equal(0.6) + func test_load_settings_with_other_sections() -> void: ## Tests load ignores/preserves other sections. ## diff --git a/tests/no_error_logs_test.py b/tests/no_error_logs_test.py new file mode 100644 index 00000000..1af4104d --- /dev/null +++ b/tests/no_error_logs_test.py @@ -0,0 +1,116 @@ +# Copyright (C) 2025 Egor Kostan +# SPDX-License-Identifier: GPL-3.0-or-later +# tests/no_error_logs_test.py +""" +Console & Page Error Integrity Test (Playwright + UI Automation) +========================================================= + +Overview +-------- +Verifies that the SkyLockAssault HTML5 build loads without triggering +any 'error' level logs or uncaught exceptions in the browser. + +Test Flow +--------- +- Listen to all console messages and uncaught page errors. +- Navigate to the index page and wait for network idle. +- Wait for window.godotInitialized (Godot _ready() signal). +- Assert that no logs with type="error" or uncaught exceptions exist. +""" + +import json +import os +import time + +from playwright.sync_api import Page + +# Configuration for stability in different environments +# Default to 5000ms, but allow CI to override via environment variable +DEFAULT_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "5000")) +BUFFER_TIMEOUT = 1000 + + +def test_no_error_logs_after_load(page: Page) -> None: + """ + E2E test to ensure zero console errors and uncaught exceptions on initial load. + """ + logs: list[dict[str, str]] = [] + page_errors: list[str] = [] + cdp_session = None + + def on_console(msg) -> None: + """Capture all console messages for inspection.""" + logs.append({"type": msg.type, "text": msg.text}) + + def on_page_error(exc) -> None: + """Capture uncaught exceptions (pageerror).""" + page_errors.append(f"Uncaught Exception: {exc.message}\n{exc.stack}") + + # Attach listeners before navigation + page.on("console", on_console) + page.on("pageerror", on_page_error) + + try: + # Start CDP session for coverage + cdp_session = page.context.new_cdp_session(page) + cdp_session.send("Profiler.enable") + cdp_session.send( + "Profiler.startPreciseCoverage", {"callCount": True, "detailed": True} + ) + + # Navigate and wait for the game to initialize + # Using the configurable DEFAULT_TIMEOUT for improved stability + page.goto( + "http://localhost:8080/index.html", + wait_until="networkidle", + timeout=DEFAULT_TIMEOUT, + ) + + # Wait for the custom Godot initialization flag + page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT) + + # Allow a short buffer for any delayed post-load errors + page.wait_for_timeout(BUFFER_TIMEOUT) + + # Filter for error logs + error_logs = [log for log in logs if log["type"] == "error"] + + # Combine errors for a comprehensive assertion + all_errors = [ + f"[{err['type']}] {err['text']}" for err in error_logs + ] + page_errors + error_details = "\n".join(all_errors) + + assert ( + len(all_errors) == 0 + ), f"Found {len(all_errors)} error(s) during load:\n{error_details}" + + except Exception as e: + print(f"Test: 'test_no_error_logs_after_load' failed: {e!s}") + os.makedirs("artifacts", exist_ok=True) + timestamp = int(time.time()) + page.screenshot(path=f"artifacts/test_error_logs_failure_{timestamp}.png") + + # Save all captured logs and exceptions for inspection + with open( + f"artifacts/test_error_logs_console_{timestamp}.txt", + "w", + encoding="utf-8", + ) as f: + f.write("--- CONSOLE LOGS ---\n") + for log in logs: + f.write(f"[{log['type']}] {log['text']}\n") + + f.write("\n--- UNCAUGHT EXCEPTIONS ---\n") + for p_err in page_errors: + f.write(f"{p_err}\n") + raise + finally: + if cdp_session: + coverage = cdp_session.send("Profiler.takePreciseCoverage")["result"] + cdp_session.send("Profiler.stopPreciseCoverage") + cdp_session.send("Profiler.disable") + with open( + "v8_coverage_no_error_logs_test.json", "w", encoding="utf-8" + ) as f: + json.dump(coverage, f) diff --git a/tests/validate_clean_load_test.py b/tests/validate_clean_load_test.py new file mode 100644 index 00000000..939e5bc9 --- /dev/null +++ b/tests/validate_clean_load_test.py @@ -0,0 +1,91 @@ +# Copyright (C) 2025 Egor Kostan +# SPDX-License-Identifier: GPL-3.0-or-later +# tests/validate_clean_load_test.py +""" +Console Error Integrity Test (Playwright + UI Automation) +========================================================= + +Overview +-------- +Specific E2E test to catch GDScript compilation and runtime errors +identified in the browser console during Godot engine initialization. + +Test Flow +--------- +- Listen for specific error patterns: "SCRIPT ERROR", "Compile Error", "Parse Error". +- Monitor for 'Uncaught (in promise)' exceptions. +- Navigate to index.html and wait for engine initialization signal. +- Fail if any critical engine or script errors are detected. +""" + +import os +import time + +from playwright.sync_api import Page + + +def test_no_critical_errors_on_load(page: Page) -> None: + """ + Verifies that the game loads without script compilation or engine errors. + + :param page: The Playwright page object. + :type page: Page + :rtype: None + """ + logs: list[dict[str, str]] = [] + + def on_console(msg) -> None: + """Capture all console messages for inspection.""" + logs.append({"type": msg.type, "text": msg.text}) + + page.on("console", on_console) + + try: + # 1. Navigate to the game + page.goto( + "http://localhost:8080/index.html", wait_until="networkidle", timeout=5000 + ) + + # 2. Wait for the engine's ready signal + page.wait_for_function("() => window.godotInitialized", timeout=5000) + + # 3. Analyze captured logs for the specific patterns + # We only check for patterns within 'error' or 'warning' logs to avoid false positives + # from informational logs that might mention these terms. + critical_errors = [ + log["text"] + for log in logs + if log["type"] in ["error", "warning"] # Filter by type first + and ( + log["type"] == "error" # All error types are critical + or any( + pattern in log["text"] + for pattern in [ + "SCRIPT ERROR", + "Compile Error", + "Parse Error", + "Failed to load script", + "Uncaught (in promise)", + ] + ) + ) + ] + + # 4. Detailed assertion + if critical_errors: + error_summary = "\n".join([f" - {err}" for err in critical_errors]) + assert ( + not critical_errors + ), f"Critical errors detected during load:\n{error_summary}" + + except Exception as e: + print(f"Load validation failed: {e!s}") + os.makedirs("artifacts", exist_ok=True) + timestamp = int(time.time()) + page.screenshot(path=f"artifacts/test_load_error_screenshot_{timestamp}.png") + + # Save logs for debugging the script failures + with open(f"artifacts/test_load_error_logs_{timestamp}.txt", "w") as f: + for log in logs: + f.write(f"[{log['type']}] {log['text']}\n") + raise