diff --git a/custom_shell.html b/custom_shell.html index adca2787a..376ec9190 100644 --- a/custom_shell.html +++ b/custom_shell.html @@ -107,7 +107,7 @@ -
+
@@ -188,22 +188,47 @@ diff --git a/files/docs/milestones/23/Part_1_Web_loading_lifecycle_progress_transition_refactor.md b/files/docs/milestones/23/Part_1_Web_loading_lifecycle_progress_transition_refactor.md new file mode 100644 index 000000000..8d625369b --- /dev/null +++ b/files/docs/milestones/23/Part_1_Web_loading_lifecycle_progress_transition_refactor.md @@ -0,0 +1,131 @@ +# Web loading lifecycle progress transition refactor + + +--- + +## PR #895 Summary: Web loading lifecycle progress transition refactor + +**Repository:** [ikostan/SkyLockAssault](https://github.com/ikostan/SkyLockAssault) +**Author:** @ikostan +**Branch:** `web-loading-lifecycle-progress-transition-refactor` → `main` +**Linked Issues:** #777, #779, #781 (and related Epic web-loading goals) +**Milestone:** Milestone 22 – Optimize Test Suite Runtime & Fix Loading Screen +**Labels:** enhancement, web, testing, CI/CD, GUI, refactoring, EPIC, QA + +### Purpose + +Improve the HTML5/WASM web loading lifecycle: add accurate assembly-transfer telemetry, harden loading-overlay accessibility and focus behavior, normalize shell handlers, and introduce a Playwright suite that validates the full splash → game transition path (including telemetry math and DOM/canvas invariants). + +### Core Improvements + +#### 1. Telemetry-Aware Engine Config (`custom_shell.html`) + +- Wrap `$GODOT_CONFIG` in a `customConfig` that injects `onProgress(current, total)` +- Log `"Telemetry - Assembly Transfer: X%"` with floor math and a guard for `total === 0` +- Initialize `Engine` with the merged config; keep `startGame()` async +- Set `window.godotInitialized = true` on successful start + +#### 2. Loading Overlay Accessibility & Focus + +- ARIA attributes on the loading container: `role="status"`, `aria-live="polite"`, `aria-busy="true"` +- On hide: set `aria-hidden="true"` and `aria-busy="false"` +- Transfer focus to `#canvas` after engine init for keyboard accessibility + +#### 3. Shell Handler Cleanup + +- Normalize options / controls / audio / advanced / gameplay back & reset button handlers +- Consistent `window.*Pressed([])` calls (no extraneous args or noisy inline comments) + +#### 4. Shared Test Config (`tests/test_utils.py`) + +- Raise default `TEST_TIMEOUT` from 7000 ms → **10000 ms** to reduce flakiness on slower WASM startups + +#### 5. Playwright Splash Transition Suite (`tests/splash_transition_flow_test.py`) + +**Unit-style telemetry checks** +- Extract `onProgress` from `custom_shell.html` and run it in isolation +- Validate percentage math, flooring, and zero-total edge cases + +**E2E flow** + +- Boot HTML5 export, track console telemetry events +- Assert progress values in 0–100 and non-decreasing progression +- Verify canvas layout (bounding box width/height > 0), overlay teardown, ARIA state, focus transfer +- Confirm `window.godotInitialized` remains true after overlay hide +- Capture failure artifacts (screenshot, logs, DOM snapshot) and V8 coverage via CDP + +### Benefits + +- Observable, accurate WASM download progress for debugging and UX +- Better accessibility (screen-reader status + post-load focus) +- Automated regression coverage for the critical splash → game path +- Cleaner, more consistent shell event wiring +- Reduced flakiness for slow web/WASM initialization under CI + +### Status Notes + +Addresses the telemetry, overlay/ARIA lifecycle, and Playwright E2E objectives of the linked tasks (#777, #779, #781) under the web loading lifecycle epic. + +--- + +## Reviewer's Guide + +Refactors the HTML5 custom shell’s web loading lifecycle by adding telemetry-aware engine configuration, improving accessibility and focus management for the loading overlay and canvas, tightening options menu handlers, and introducing Playwright-based tests that validate the splash transition flow and onProgress telemetry math. + +### File-Level Changes + +| Change | Details | Files | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------| +| Inject telemetry-aware onProgress callback into the engine configuration and adjust loading lifecycle behavior around engine startup/failure. | | `custom_shell.html` | +| Improve accessibility of the loading overlay and ensure post-load focus transfer to the canvas. | | `custom_shell.html` | +| Normalize options UI back/reset button handlers to use consistent callback invocations. | | `custom_shell.html` | +| Relax shared Playwright test timeout configuration to better accommodate slower web/WASM initialization. | | `tests/test_utils.py` | +| Add a Playwright-based splash transition and telemetry test suite, including isolated unit-style tests for the onProgress callback and a comprehensive E2E flow. | | `tests/splash_transition_flow_test.py` | +| Document the web loading lifecycle progress transition refactor and its relation to milestones and issues. | | `files/docs/milestones/23/Part_1_Web_loading_lifecycle_progress_transition_refactor.md` | + +### Assessment against linked issues + +| Issue | Objective | Addressed | Explanation | +|------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------|-------------| +| https://github.com/ikostan/SkyLockAssault/issues/777 | Implement telemetry-aware engine initialization hooks in the HTML custom shell to track progressive WebAssembly/binary streaming progress independently of the visual loading bar. | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/777 | Improve the loading overlay’s visibility, ARIA/accessibility state, and focus behavior to coordinate with engine startup and mitigate WebGL layout/black-flash issues during the splash-to-canvas transition. | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/777 | Add an automated Playwright-based browser test suite that validates the splash/loading transition flow, including telemetry logging behavior, canvas/DOM invariants, and absence of critical startup faults. | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/779 | Refactor custom_shell.html engine initialization to use a consolidated customConfig object derived from $GODOT_CONFIG, injecting an onProgress(current, total) callback that logs "Telemetry - Assembly Transfer: X%" and binding it via var engine = new Engine(customConfig). | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/779 | Adjust the initialization lifecycle in custom_shell.html so that web loading progress is observable in the console, the engine binds cleanly to the WebGL canvas, and the #loading overlay is torn down with appropriate accessibility attributes (e.g., aria-hidden) once application initialization finishes. | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/781 | Create tests/splash_transition_flow_test.py using Python + Playwright that follows project testing conventions, including explicit type annotations and importing shared lifecycle dependencies (e.g., test_utils). | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/781 | Implement an end-to-end Playwright flow that establishes a CDP session for V8 coverage, injects console logging hooks to capture and filter "Telemetry - Assembly Transfer:" events, and enforces a synchronization chain asserting #loading visibility, waiting for window.godotInitialized, and validating #canvas rendering/overlay teardown. | ✅ | | +| https://github.com/ikostan/SkyLockAssault/issues/781 | Add a defensive failure trap in the splash transition test that catches exceptions and writes runtime crash state artifacts (timestamped screenshots, console/page-error logs, and DOM HTML snapshots) into the shared artifacts/ directory. | ✅ | | + +### Possibly linked issues + +- **#EPIC**: PR fulfills epic’s HTML shell telemetry, accessibility, and Playwright E2E testing requirements for web loading lifecycle. +- **#TASK-02**: PR introduces customConfig with onProgress telemetry in custom_shell.html and refines loading overlay/ARIA per TASK-02. + +--- + +## PR #895 Summary: Bots / AI Contributions + +### AI / Bot Contributors + +- **@sourcery-ai** + Generated the PR summary and Reviewer’s Guide. Performed code review with suggestions (e.g., gating noisy `onProgress` console logging behind a debug flag, and relaxing strict monotonicity checks to allow equal consecutive progress values). + +- **@coderabbitai** + Generated the PR summary, walkthrough, and poem. Conducted code reviews. Authored the “CodeRabbit Generated Unit Tests” commit and co-authored a later test update commit. + +- **@deepsource-io** + Performed automated DeepSource Code Review and published a PR Report Card (Security / Reliability / Complexity / Hygiene). + +- **@deepsource-autofix** + Authored multiple automated style/format commits (`style: format code with Black and isort`) to enforce Black + isort consistency. + +- **@copilot** (GitHub Copilot) + Co-authored the commit that enhanced splash transition telemetry checks (regex parsing of progress marks, bounds/monotonic assertions, and related test hardening). + +### Human Contributor + +- **@ikostan** + Primary author of the PR. Implemented the web loading lifecycle refactor: custom `onProgress` telemetry in `custom_shell.html`, ARIA accessibility and canvas focus handling for the loading overlay, normalized options button handlers, increased shared test timeouts, and the full Playwright `splash_transition_flow_test.py` suite (telemetry unit checks, E2E flow, canvas/ARIA invariants, failure artifacts, V8 coverage). Authored the majority of commits and iteratively refined the implementation and tests. + +--- + diff --git a/tests/splash_transition_flow_test.py b/tests/splash_transition_flow_test.py new file mode 100644 index 000000000..994b676d0 --- /dev/null +++ b/tests/splash_transition_flow_test.py @@ -0,0 +1,349 @@ +# Copyright (C) 2025-2026 Egor Kostan +# SPDX-License-Identifier: GPL-3.0-or-later +# tests/splash_transition_flow_test.py +""" +Splash Screen Transition & Telemetry Test Suite (Playwright + UI Automation) +============================================================================= + +Overview +-------- +Verifies the asynchronous web loading workflow, custom shell initialization +pipeline, progressive assembly telemetry, and in-game progress transition +mechanics. Eliminates race conditions by validating orderly handshakes +between the DOM layout engine and the WebAssembly runtime graphics context. + +Prerequisites +------------- +- http://localhost:8080/index.html (HTML5 export with preloader & overlays) +- pytest, playwright + +Running +------- +pytest -k splash_transition_flow -q +""" + +import re +import time +from pathlib import Path +from typing import Any + +from playwright.sync_api import Page, expect + +from tests.test_utils import ( + ARTIFACTS_DIR, + DEFAULT_TIMEOUT, + TEST_TIMEOUT, + init_cdp_coverage, + save_v8_coverage, +) + +_CUSTOM_SHELL_PATH = Path(__file__).resolve().parents[1] / "custom_shell.html" +_ON_PROGRESS_MARKER = "onProgress: function(current, total)" + + +# ============================================================================== +# Helper Functions for Isolated JS Unit Tests +# ============================================================================== + + +def _extract_on_progress_function_source() -> str: + """Extracts onProgress telemetry callback verbatim from custom_shell.html. + + Uses `_ON_PROGRESS_MARKER` to locate callback and parses balanced braces. + """ + html = _CUSTOM_SHELL_PATH.read_text(encoding="utf-8") + + marker_index = html.find(_ON_PROGRESS_MARKER) + if marker_index == -1: + raise AssertionError( + "onProgress telemetry handler marker not found in custom_shell.html" + ) + + tail = html[marker_index:] + relative_func_index = tail.find("function") + if relative_func_index == -1: + raise AssertionError( + "onProgress handler function definition not found after marker" + ) + + func_start = marker_index + relative_func_index + brace_start = html.find("{", func_start) + if brace_start == -1: + raise AssertionError("onProgress handler function body opening brace not found") + + depth = 0 + func_end: int | None = None + for i, ch in enumerate(html[brace_start:], start=brace_start): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + func_end = i + break + + if func_end is None or depth != 0: + raise AssertionError("onProgress handler function body has unbalanced braces") + + return html[func_start : func_end + 1].strip() + + +def _run_on_progress( + page: Page, fn_source: str, calls: list[tuple[int, int]] +) -> list[str]: + """Evaluates the extracted callback in an isolated JS context.""" + return page.evaluate( + """([fnSource, calls]) => { + const onProgress = new Function('return (' + fnSource + ')')(); + const outputs = []; + const originalLog = console.log; + console.log = (msg) => { outputs.push(msg); }; + try { + for (const [current, total] of calls) { + onProgress(current, total); + } + } finally { + console.log = originalLog; + } + return outputs; + }""", + [fn_source, calls], + ) + + +# ============================================================================== +# Fast Unit Tests (Run in ~20ms, No WASM Boot Required) +# ============================================================================== + + +def test_on_progress_telemetry_math_is_correct(page: Page) -> None: + """Validates percentage calculations in isolated JS context.""" + fn_source = _extract_on_progress_function_source() + outputs = _run_on_progress( + page, fn_source, [(0, 200), (50, 200), (100, 200), (200, 200)] + ) + assert outputs == [ + "Telemetry - Assembly Transfer: 0%", + "Telemetry - Assembly Transfer: 25%", + "Telemetry - Assembly Transfer: 50%", + "Telemetry - Assembly Transfer: 100%", + ] + + +def test_on_progress_floors_fractional_percentages(page: Page) -> None: + """Regression test: fractional percentages must be floored, not rounded.""" + fn_source = _extract_on_progress_function_source() + outputs = _run_on_progress( + page, + fn_source, + [(1, 3), (2, 3), (99, 100)], + ) + assert outputs == [ + "Telemetry - Assembly Transfer: 33%", + "Telemetry - Assembly Transfer: 66%", + "Telemetry - Assembly Transfer: 99%", + ] + + +def test_on_progress_skips_logging_when_total_is_zero(page: Page) -> None: + """Guards against division-by-zero (NaN% or Infinity%) when total is 0.""" + fn_source = _extract_on_progress_function_source() + outputs = _run_on_progress(page, fn_source, [(0, 0), (5, 0)]) + assert outputs == [], f"Expected no logs when total=0, got: {outputs}" + + +# ============================================================================== +# E2E Stage Validation Helpers (Cyclomatic Complexity Reduction) +# ============================================================================== + + +def _validate_telemetry_stream(logs: list[dict[str, str]]) -> None: + """Validates presence, progression, and formatting of telemetry marks.""" + progress_values: list[int] = [] + for log in logs: + match = re.search( + r"Telemetry - Assembly Transfer:\s*(\d+)%", + log["text"], + ) + if match: + progress_values.append(int(match.group(1))) + + assert ( + len(progress_values) > 0 + ), "No 'Telemetry - Assembly Transfer:' marks captured during load." + assert all( + 0 <= val <= 100 for val in progress_values + ), f"Telemetry percentage out of bounds [0, 100]: {progress_values}" + assert progress_values == sorted(progress_values), ( + "Assembly transfer telemetry did not progress monotonically: " + f"{progress_values}" + ) + assert max(progress_values) >= 90, ( + "Assembly transfer telemetry never approached completion: " f"{progress_values}" + ) + + malformed = [ + log["text"] + for log in logs + if "Telemetry - Assembly Transfer:" in log["text"] + and not re.search(r"Telemetry - Assembly Transfer:\s*\d+%$", log["text"]) + ] + assert malformed == [], f"Malformed telemetry entries: {malformed}" + + +def _validate_canvas_and_dom_invariants(page: Page, loading_overlay: Any) -> None: + """Validates canvas layout, overlay teardown, and initialized state.""" + canvas_element = page.locator("#canvas") + expect(canvas_element).to_be_visible(timeout=TEST_TIMEOUT) + + canvas_box = canvas_element.bounding_box() + assert canvas_box is not None, "Canvas element has no rendered bounding box" + assert ( + canvas_box["width"] > 0 + ), "Canvas rendered width is zero (viewport layout failure)" + assert ( + canvas_box["height"] > 0 + ), "Canvas rendered height is zero (viewport layout failure)" + + assert ( + "SkyLockAssault" in page.title() + ), f"Target application title mismatch: '{page.title()}'" + + expect(loading_overlay).to_be_hidden(timeout=TEST_TIMEOUT) + assert page.evaluate( + "() => document.getElementById('loading')" + ".getAttribute('aria-hidden') === 'true'" + ), "Loading container missing aria-hidden='true' post-initialization" + assert page.evaluate( + "() => document.getElementById('loading')" + ".getAttribute('aria-busy') === 'false'" + ), "Loading container missing aria-busy='false' post-initialization" + assert page.evaluate( + "() => document.activeElement === document.getElementById('canvas')" + ), "Canvas did not receive focus after initialization" + + assert page.evaluate( + "() => window.godotInitialized === true" + ), "window.godotInitialized lost state after splash transition" + + +def _assert_no_critical_faults( + logs: list[dict[str, str]], page_errors: list[str] +) -> None: + """Asserts that no fatal exceptions occurred during load.""" + critical_faults = [ + log["text"] + for log in logs + if log["type"] == "error" + and not any( + phrase in log["text"].lower() + for phrase in ["encryption aborted", "salt is empty"] + ) + ] + page_errors + + assert ( + len(critical_faults) == 0 + ), "Critical exceptions found during web handshake:\n" + "\n".join(critical_faults) + + +def _save_failure_artifacts( + page: Page, logs: list[dict[str, str]], page_errors: list[str] +) -> None: + """Captures screenshot, logs, and DOM snapshot to ARTIFACTS_DIR on error.""" + timestamp = int(time.time()) + + # 1. Screenshot (best-effort) + screenshot_path = ARTIFACTS_DIR / f"test_splash_failure_screenshot_{timestamp}.png" + try: + page.screenshot(path=str(screenshot_path)) + except Exception: + # Best-effort screenshot; ignore errors so we don't mask the original failure + pass + + # 2. Console & Page Error Logs + logs_path = ARTIFACTS_DIR / f"test_splash_failure_logs_{timestamp}.txt" + try: + with open(logs_path, "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--- PAGE ERRORS ---\n") + for p_err in page_errors: + f.write(f"{p_err}\n") + except Exception: + pass + + # 3. DOM HTML Snapshot (best-effort) + html_path = ARTIFACTS_DIR / f"test_splash_failure_html_{timestamp}.html" + try: + with open(html_path, "w", encoding="utf-8") as f: + f.write(page.content()) + except Exception: + pass + + +# ============================================================================== +# Comprehensive E2E Test (Single WASM Boot) +# ============================================================================== + + +# DO NOT REFACTOR: Must consume function-scoped `page` to capture preloader +# and early startup telemetry. +def test_splash_transition_flow(page: Page) -> None: + """ + Validates assembly stream metrics, progressive telemetry monotonicity, + WebGL frame canvas presentation, and orderly removal of preloader DOM. + """ + logs: list[dict[str, str]] = [] + page_errors: list[str] = [] + + def on_console(msg: Any) -> None: + """Capture all console logs to track runtime lifecycle telemetry.""" + logs.append({"type": msg.type, "text": msg.text}) + + def on_page_error(exc: Any) -> None: + """Capture uncaught runtime errors during engine boot.""" + page_errors.append(f"Uncaught Exception: {exc.message}\n{exc.stack}") + + page.on("console", on_console) + page.on("pageerror", on_page_error) + + # 1. Initialize V8 coverage + cdp_session, _ = init_cdp_coverage(page) + + try: + # 2. Navigate and verify initial preloader visibility + page.goto( + "http://localhost:8080/index.html", + wait_until="domcontentloaded", + timeout=DEFAULT_TIMEOUT, + ) + + loading_overlay = page.locator("#loading") + expect(loading_overlay).to_be_visible(timeout=TEST_TIMEOUT) + + # 3. Wait for WASM initialization + page.wait_for_function( + "() => window.godotInitialized === true", + timeout=DEFAULT_TIMEOUT, + ) + + # 4. Run extracted assertions + _validate_telemetry_stream(logs) + _validate_canvas_and_dom_invariants(page, loading_overlay) + _assert_no_critical_faults(logs, page_errors) + + except Exception as e: + print(f"Test: 'test_splash_transition_flow' failed: {e!s}") + _save_failure_artifacts(page, logs, page_errors) + raise + + finally: + try: + page.remove_listener("console", on_console) + page.remove_listener("pageerror", on_page_error) + except Exception: + pass + + # 5. Harvest & save coverage + save_v8_coverage(cdp_session, "splash_transition_flow_test") diff --git a/tests/test_utils.py b/tests/test_utils.py index 855ac47d4..140782726 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -15,7 +15,7 @@ # Shared timeout configurations across test suites DEFAULT_TIMEOUT = int(os.getenv("DEFAULT_TIMEOUT", "30000")) -TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "7000")) +TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "15000")) LOG_LEVEL_MAP: dict[str, int] = { "DEBUG": 0,