diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 0a87ac6..ecfe262 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -10,8 +10,8 @@ description: > Correction (aviorstudio/fieldsofrevik#148): the earlier text said this one definition guarded release, but the GD release bypassed it and this action omitted the shipped JavaScript suite. CI and Release now call this action, - which runs Go, Godot, JavaScript, runner controls, and the exact GD package - lifecycle gate. + which runs Go, Godot, JavaScript, runner controls, the exact GD package + lifecycle gate, and ordinary/diagnostic release-export browser checks. A per-repo copy, not a shared action. `uses: ./` is repo-local, so the fourteen copies of this file are copies -- the accepted cost of no repo's CI @@ -83,3 +83,42 @@ runs: - name: Install exact ZIP and verify editor lifecycle shell: bash run: bash gd/tests/package_lifecycle_test.sh dist/@aviorstudio_gd-playwright.zip + + - name: Install verified Godot web export templates + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/Godot_v4.7.2-stable_export_templates.tpz" + unpack="$RUNNER_TEMP/gd-playwright-export-templates" + destination="${XDG_DATA_HOME:-$HOME/.local/share}/godot/export_templates/4.7.2.stable" + curl --fail --location --retry 3 --max-time 300 \ + https://github.com/godotengine/godot-builds/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz \ + --output "$archive" + printf '%s %s\n' 'ca4d71c4d7b81dfc15d1a98baa07534aa95b03fdda78a0075b06672e1648d2e5f40980c9adc28d23e1b92e732ee7bf3461997aa804af74ec2fcd7a93ccb84079' "$archive" | sha512sum --check + rm -rf "$unpack" "$destination" + mkdir -p "$unpack" "$destination" + unzip -q "$archive" -d "$unpack" + cp -a "$unpack/templates/." "$destination/" + test -s "$destination/web_release.zip" + + - name: Install pinned Playwright CLI and Chromium + shell: bash + run: | + set -euo pipefail + npm install --global @playwright/cli@0.1.18 + npx --yes playwright@1.63.0-alpha-2026-08-05 install --with-deps chromium + test "$(playwright-cli --version)" = "0.1.18" + + - name: Verify ordinary and diagnostic web release artifacts + shell: bash + env: + GD_WEB_OUTPUT_DIR: ${{ github.workspace }}/dist/web-export-evidence + run: bash gd/tests/web_export_test.sh dist/@aviorstudio_gd-playwright.zip + + - name: Upload web release acceptance evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: gd-web-evidence-${{ github.run_id }}-${{ github.job }} + path: dist/web-export-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f14958..b30bccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI # Every pull request and every merge to main: test, on one runner. # # The common action runs every shipped suite and verifies the exact assembled GD -# release ZIP through clean editor enable/restart/disable/restart lifecycle. +# release ZIP through clean editor lifecycle and ordinary/diagnostic web exports. on: pull_request: @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest # Bounded, so a step that hangs fails here rather than sitting until the # runner's own timeout hours later. - timeout-minutes: 20 + timeout-minutes: 30 steps: # Third-party actions are pinned by SHA, with the tag in a trailing # comment so the version is still readable. A tag is a moving reference: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a6f148..bbac652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: permissions: contents: read runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 outputs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} diff --git a/README.md b/README.md index 0b7451f..cefbcad 100644 --- a/README.md +++ b/README.md @@ -265,11 +265,27 @@ The Godot addon writes generic browser globals during enabled web runs: ## Safety Notes -- Features only run in web builds when debug mode, `enabled`, or `test_mode` is active. The production-export choice remains tracked by [fieldsofrevik#148](https://github.com/aviorstudio/fieldsofrevik/issues/148); this release does not choose between excluding diagnostics and a dedicated production automation feature. +- Debug web builds retain the existing `enabled`/`test_mode` behavior. Ordinary release exports never create gd-playwright browser globals, even when those settings are enabled. +- Production diagnostics require a separate export preset with the custom feature `gd_playwright_diagnostics`. Configure `PlaywrightConfig` with a `PlaywrightPayloadPolicy`: exact element keys and prefixes, event-name to allowed-field rules, and state-namespace to allowed-field rules. Empty or missing rules deny publication. +- Diagnostic release payloads must contain only JSON-safe values. Known credential-like keys (including `token`, `password`, `secret`, cookies, session values, API keys, and private keys) are rejected recursively. This is a bounded guard, not a confidentiality or exhaustive secret-detection guarantee; games remain responsible for publishing only non-sensitive diagnostic data. - Calls are safe to leave in game code because disabled features no-op. - Do not expose private player data through test state or event payloads. - Game-specific knowledge belongs in game docs or skills, not in `gdpw`. +Example diagnostic release configuration: + +```gdscript +var policy := PlaywrightServiceModule.PlaywrightPayloadPolicy.new( + PackedStringArray(["start_button"]), + PackedStringArray(["unit_"]), + {"route_loaded": PackedStringArray(["route"])}, + {"fixture": PackedStringArray(["route", "ready"])} +) +PlaywrightService.configure( + PlaywrightServiceModule.PlaywrightConfig.new(true, true, false, 100, 50, policy) +) +``` + ## Repository Layout - `gd/addon/`: Godot plugin source packaged for GDAM and manual installation. @@ -294,11 +310,12 @@ Run locally with: ```sh mise exec -- ./gd/tests/test.sh +mise exec -- bash gd/tests/web_export_test.sh dist/@aviorstudio_gd-playwright.zip cd cli && mise exec -- go test ./... cd js && mise exec -- bun test ``` -**Correction ([fieldsofrevik#148](https://github.com/aviorstudio/fieldsofrevik/issues/148)):** this README previously said CI ran all three suites, while the common action omitted `js/index.test.js` and the GD release bypassed the common Godot gate. CI and both release targets now run Go, Godot 4.7.2, and JavaScript tests. The GD path additionally tests the exact closed-manifest ZIP through clean editor enable, restart, disable, restart, smoke, and ownership-cleanup checks before transporting those same bytes to publication. +**Correction ([fieldsofrevik#148](https://github.com/aviorstudio/fieldsofrevik/issues/148)):** this README previously said CI ran all three suites, while the common action omitted `js/index.test.js` and the GD release bypassed the common Godot gate. CI and both release targets now run Go, Godot 4.7.2, and JavaScript tests. The GD path additionally tests the exact closed-manifest ZIP through clean editor enable, restart, disable, restart, smoke, ownership-cleanup, and ordinary/diagnostic release-export browser checks before transporting those same bytes to publication. ## License diff --git a/docs/evidence/issue-148/diagnostic-release.png b/docs/evidence/issue-148/diagnostic-release.png new file mode 100644 index 0000000..d41bfad Binary files /dev/null and b/docs/evidence/issue-148/diagnostic-release.png differ diff --git a/docs/evidence/issue-148/ordinary-release.png b/docs/evidence/issue-148/ordinary-release.png new file mode 100644 index 0000000..ea0f4ec Binary files /dev/null and b/docs/evidence/issue-148/ordinary-release.png differ diff --git a/gd/addon/src/element_map_service.gd b/gd/addon/src/element_map_service.gd index e6cc583..51b7445 100644 --- a/gd/addon/src/element_map_service.gd +++ b/gd/addon/src/element_map_service.gd @@ -51,6 +51,8 @@ func setup(owner: Node) -> void: func register(key: String, center: Vector2, size: Vector2, visible: bool) -> void: if key.is_empty(): return + if _owner != null and _owner.has_method("_allows_element_key") and not bool(_owner.call("_allows_element_key", key)): + return var entry := ElementEntry.new( key, int(center.x), @@ -144,7 +146,10 @@ func flush_to_browser() -> void: }; window.dispatchEvent(new CustomEvent('godot-elements-updated', { detail: __payload })); """ % json_string - JavaScriptBridge.eval(js_code) + if _owner != null and _owner.has_method("_browser_eval"): + _owner.call("_browser_eval", js_code) + else: + JavaScriptBridge.eval(js_code) ## Clears all registered elements. func clear() -> void: diff --git a/gd/addon/src/playwright_service.gd b/gd/addon/src/playwright_service.gd index a40a222..28d116f 100644 --- a/gd/addon/src/playwright_service.gd +++ b/gd/addon/src/playwright_service.gd @@ -9,6 +9,95 @@ const ElementMapService = preload("element_map_service.gd") const PlaywrightTagNode = preload("playwright_tag_node.gd") const META_KEY := "playwright" +const DIAGNOSTICS_EXPORT_FEATURE := "gd_playwright_diagnostics" + +## Explicit production diagnostics allowlist. Empty collections deny every +## game-specific element, event, and state payload in diagnostic release builds. +class PlaywrightPayloadPolicy extends RefCounted: + const SENSITIVE_KEYS := [ + "access_token", "api_key", "authorization", "cookie", "password", + "private_key", "refresh_token", "secret", "session", "token" + ] + var element_keys: PackedStringArray = PackedStringArray() + var element_prefixes: PackedStringArray = PackedStringArray() + var event_fields: Dictionary = {} + var state_fields: Dictionary = {} + + func _init( + allowed_element_keys: PackedStringArray = PackedStringArray(), + allowed_element_prefixes: PackedStringArray = PackedStringArray(), + allowed_event_fields: Dictionary = {}, + allowed_state_fields: Dictionary = {} + ) -> void: + element_keys = allowed_element_keys.duplicate() + element_prefixes = allowed_element_prefixes.duplicate() + event_fields = allowed_event_fields.duplicate(true) + state_fields = allowed_state_fields.duplicate(true) + + func allows_element(key: String) -> bool: + if key in element_keys: + return true + for prefix: String in element_prefixes: + if not prefix.is_empty() and key.begins_with(prefix): + return true + return false + + func allows_event(event_name: String, payload: Dictionary) -> bool: + return _allows_dictionary(event_fields, event_name, payload) + + func allows_state(state_namespace: String, state: Dictionary) -> bool: + return _allows_dictionary(state_fields, state_namespace, state) + + func _allows_dictionary(rules: Dictionary, rule_name: String, payload: Dictionary) -> bool: + if not rules.has(rule_name) or _contains_sensitive_key(payload): + return false + var allowed_fields: PackedStringArray = _as_string_array(rules[rule_name]) + for key: Variant in payload: + if str(key) not in allowed_fields or not _is_json_safe(payload[key]): + return false + return true + + func _contains_sensitive_key(value: Variant) -> bool: + if value is Dictionary: + for key: Variant in value: + if str(key).to_snake_case().to_lower() in SENSITIVE_KEYS: + return true + if _contains_sensitive_key(value[key]): + return true + elif value is Array: + for item: Variant in value: + if _contains_sensitive_key(item): + return true + return false + + func _is_json_safe(value: Variant) -> bool: + if value == null or value is bool or value is String: + return true + if value is int: + return true + if value is float: + return is_finite(value) + if value is Array: + for item: Variant in value: + if not _is_json_safe(item): + return false + return true + if value is Dictionary: + for key: Variant in value: + if not (key is String) or not _is_json_safe(value[key]): + return false + return true + return false + + func _as_string_array(value: Variant) -> PackedStringArray: + if value is PackedStringArray: + return value + var result := PackedStringArray() + if value is Array: + for item: Variant in value: + if item is String: + result.append(item) + return result ## Runtime configuration for browser event emission behavior. class PlaywrightConfig extends RefCounted: @@ -17,19 +106,22 @@ class PlaywrightConfig extends RefCounted: var log_events: bool = true var buffer_max: int = 1000 var buffer_trim: int = 500 + var payload_policy: PlaywrightPayloadPolicy = null func _init( enabled: bool = false, test_mode: bool = false, log_events: bool = true, buffer_max: int = 1000, - buffer_trim: int = 500 + buffer_trim: int = 500, + payload_policy: PlaywrightPayloadPolicy = null ) -> void: self.enabled = enabled self.test_mode = test_mode self.log_events = log_events self.buffer_max = buffer_max self.buffer_trim = buffer_trim + self.payload_policy = payload_policy const SETTINGS_PREFIX := "gd_playwright/" @@ -45,21 +137,29 @@ const DEFAULT_EVENT_BUFFER_TRIM := 500 var _config: PlaywrightConfig = null var _element_map: ElementMapService = null +var _browser_owner_id: String = "" func configure(config: PlaywrightConfig) -> void: _config = config if config else _config_from_project_settings() + if not _is_web_runtime(): + return + if _should_emit_events(): + _claim_browser_bridge() + else: + _cleanup_browser_bridge() func get_config() -> PlaywrightConfig: return _config func _ready() -> void: - if not OS.has_feature("web"): + if not _is_web_runtime(): return if not _is_test_mode_enabled(): return _on_test_mode_ready() func _on_test_mode_ready() -> void: + _claim_browser_bridge() _element_map = ElementMapService.new() _element_map.setup(self) emit_event("service_ready") @@ -77,6 +177,8 @@ func get_element_map() -> ElementMapService: ## Registers an element position directly without requiring a PlaywrightTag node. ## Use this for runtime-created or non-Node2D/Control test targets. func register_element(key: String, center: Vector2, element_size: Vector2, visible: bool = true) -> void: + if not _allows_element_key(key.strip_edges()): + return var element_map_service: ElementMapService = get_element_map() if element_map_service == null: return @@ -124,9 +226,12 @@ func set_test_state(state_namespace_name: String, state: Dictionary) -> void: var state_namespace: String = state_namespace_name.strip_edges() if state_namespace.is_empty(): return + if _requires_payload_policy() and not _resolve_payload_policy().allows_state(state_namespace, state): + return var json_string: String = JSON.stringify(state) var namespace_json: String = JSON.stringify(state_namespace) - JavaScriptBridge.eval("window.godotTestState = window.godotTestState || {}; window.godotTestState[%s] = %s;" % [namespace_json, json_string]) + _claim_browser_bridge() + _browser_eval("window.godotTestState = window.godotTestState || {}; window.godotTestState[%s] = %s;" % [namespace_json, json_string]) ## Clears one window.godotTestState namespace. ## No-op when the service is disabled. @@ -137,7 +242,8 @@ func clear_test_state(state_namespace_name: String) -> void: if state_namespace.is_empty(): return var namespace_json: String = JSON.stringify(state_namespace) - JavaScriptBridge.eval("if (window.godotTestState) { delete window.godotTestState[%s]; }" % namespace_json) + _claim_browser_bridge() + _browser_eval("if (window.godotTestState) { delete window.godotTestState[%s]; }" % namespace_json) ## Called by ElementMapService via deferred call when the map is dirty. ## No-op when the service is disabled. @@ -145,6 +251,7 @@ func _on_element_map_flush_requested() -> void: if not _should_emit_events(): return if _element_map != null: + _claim_browser_bridge() _element_map.flush_to_browser() ## Scans the current scene tree for nodes with set_meta("playwright", "key") @@ -198,6 +305,8 @@ func emit_event(event_name: String, payload: Dictionary = {}) -> void: func emit_event_to_browser(event_name: String, data: Dictionary = {}) -> void: if not _should_emit_events(): return + if _requires_payload_policy() and not _resolve_payload_policy().allows_event(event_name, data): + return var event_data := { "event": event_name, @@ -209,7 +318,8 @@ func emit_event_to_browser(event_name: String, data: Dictionary = {}) -> void: var config: PlaywrightConfig = _resolve_config() if config.log_events: - JavaScriptBridge.eval("console.log('[GD_PLAYWRIGHT_EVENT]', " + json_string + ")") + _claim_browser_bridge() + _browser_eval("console.log('[GD_PLAYWRIGHT_EVENT]', " + json_string + ")") var buffer_max: int = maxi(config.buffer_max, 0) var buffer_trim: int = maxi(config.buffer_trim, 0) @@ -229,21 +339,76 @@ func emit_event_to_browser(event_name: String, data: Dictionary = {}) -> void: window.godotEvents.push(%s); window.dispatchEvent(new CustomEvent('godot-event', { detail: %s })); """ % [json_string, json_string] - JavaScriptBridge.eval(js_code) + _claim_browser_bridge() + _browser_eval(js_code) func _should_emit_events() -> bool: - if not OS.has_feature("web"): + if not _is_web_runtime(): return false var config: PlaywrightConfig = _resolve_config() - - if _is_test_mode_enabled(): + if _is_debug_runtime(): return true + if not _has_diagnostics_export_feature(): + return false + return _is_test_mode_enabled() or config.enabled - if config.enabled: - return true +func _exit_tree() -> void: + _cleanup_browser_bridge() + +func _claim_browser_bridge() -> void: + if not _is_web_runtime(): + return + if _browser_owner_id.is_empty(): + _browser_owner_id = "%s:%s" % [str(get_instance_id()), str(Time.get_ticks_usec())] + _browser_eval("window.__gdPlaywrightOwner = %s;" % JSON.stringify(_browser_owner_id)) + +func _cleanup_browser_bridge() -> void: + if not _is_web_runtime() or _browser_owner_id.is_empty(): + return + var owner_json := JSON.stringify(_browser_owner_id) + _browser_eval(""" + if (window.__gdPlaywrightOwner === %s) { + var __waiters = window.__gdPlaywrightEventWaiters; + if (__waiters instanceof Map) { + for (var __waiter of __waiters.values()) { + if (__waiter && typeof __waiter.cancel === 'function') { __waiter.cancel(); } + } + } + delete window.__gdPlaywrightEventWaiters; + delete window.godotElements; + delete window.godotElementsViewport; + delete window.godotEvents; + delete window.godotTestState; + delete window.__gdPlaywrightOwner; + window.dispatchEvent(new CustomEvent('gd-playwright-cleanup', { detail: { owner: %s } })); + } + """ % [owner_json, owner_json]) + _browser_owner_id = "" + +func _browser_eval(code: String) -> Variant: + return JavaScriptBridge.eval(code) + +func _is_web_runtime() -> bool: + return OS.has_feature("web") +func _is_debug_runtime() -> bool: return OS.is_debug_build() +func _has_diagnostics_export_feature() -> bool: + return OS.has_feature(DIAGNOSTICS_EXPORT_FEATURE) + +func _requires_payload_policy() -> bool: + return _is_web_runtime() and not _is_debug_runtime() and _has_diagnostics_export_feature() + +func _resolve_payload_policy() -> PlaywrightPayloadPolicy: + var config := _resolve_config() + if config.payload_policy == null: + config.payload_policy = PlaywrightPayloadPolicy.new() + return config.payload_policy + +func _allows_element_key(key: String) -> bool: + return not _requires_payload_policy() or _resolve_payload_policy().allows_element(key) + func _is_test_mode_enabled() -> bool: var config: PlaywrightConfig = _resolve_config() return config.test_mode diff --git a/gd/tests/playwright_service_test.gd b/gd/tests/playwright_service_test.gd index b294945..e36176c 100644 --- a/gd/tests/playwright_service_test.gd +++ b/gd/tests/playwright_service_test.gd @@ -12,12 +12,37 @@ class FakePlaywrightService extends PlaywrightServiceModule: captured_payload = data.duplicate(true) call_count += 1 +class RecordingWebService extends PlaywrightServiceModule: + var scripts: Array[String] = [] + + func _is_web_runtime() -> bool: + return true + + func _is_debug_runtime() -> bool: + return false + + func _has_diagnostics_export_feature() -> bool: + return true + + func _browser_eval(code: String) -> Variant: + scripts.append(code) + return null + +class OrdinaryWebService extends RecordingWebService: + func _has_diagnostics_export_feature() -> bool: + return false + func _initialize() -> void: var failures: Array[String] = [] _test_emit_event_delegates_to_browser_emitter(failures) _test_emit_namespaced_event_delegates_to_browser_emitter(failures) _test_configure_retains_buffer_and_flag_settings(failures) _test_meta_key_constant(failures) + _test_cleanup_is_owner_guarded_and_complete(failures) + _test_stale_instance_cleanup_cannot_claim_another_owner(failures) + _test_production_payload_policy_is_default_deny(failures) + _test_production_payload_policy_allows_only_declared_safe_fields(failures) + _test_ordinary_release_cannot_enable_bridge_by_setting(failures) if failures.is_empty(): print("PASS gd-playwright playwright_service_test") @@ -72,3 +97,90 @@ func _test_configure_retains_buffer_and_flag_settings(failures: Array[String]) - func _test_meta_key_constant(failures: Array[String]) -> void: if PlaywrightServiceModule.META_KEY != "playwright": failures.append("Expected META_KEY to be 'playwright', got '%s'" % PlaywrightServiceModule.META_KEY) + +func _test_cleanup_is_owner_guarded_and_complete(failures: Array[String]) -> void: + var service := RecordingWebService.new() + service.configure(PlaywrightServiceModule.PlaywrightConfig.new(true, false, false)) + var owner_id := service._browser_owner_id + service._cleanup_browser_bridge() + if service.scripts.size() != 2: + failures.append("Expected one bridge claim and one cleanup script") + service.free() + return + var cleanup := service.scripts[1] + if not cleanup.contains("window.__gdPlaywrightOwner ===") or not cleanup.contains(owner_id): + failures.append("Expected cleanup to require the current service owner identity") + for global_name: String in ["godotElements", "godotElementsViewport", "godotEvents", "godotTestState", "__gdPlaywrightEventWaiters"]: + if not cleanup.contains("delete window." + global_name): + failures.append("Expected cleanup to remove owned global " + global_name) + if not cleanup.contains("__waiter.cancel()"): + failures.append("Expected cleanup to cancel helper listeners before deleting their registry") + if not service._browser_owner_id.is_empty(): + failures.append("Expected local browser owner identity to clear after cleanup") + service.free() + +func _test_stale_instance_cleanup_cannot_claim_another_owner(failures: Array[String]) -> void: + var first := RecordingWebService.new() + var second := RecordingWebService.new() + first.configure(PlaywrightServiceModule.PlaywrightConfig.new(true, false, false)) + second.configure(PlaywrightServiceModule.PlaywrightConfig.new(true, false, false)) + var first_owner := first._browser_owner_id + var second_owner := second._browser_owner_id + if first_owner == second_owner: + failures.append("Expected independent service instances to use unique browser owners") + first._cleanup_browser_bridge() + var stale_cleanup := first.scripts[-1] + if not stale_cleanup.contains(first_owner) or stale_cleanup.contains(second_owner): + failures.append("Expected stale cleanup to be scoped only to the stale owner") + second._cleanup_browser_bridge() + first.free() + second.free() + +func _test_production_payload_policy_is_default_deny(failures: Array[String]) -> void: + var service := RecordingWebService.new() + service.configure(PlaywrightServiceModule.PlaywrightConfig.new(true, false, false)) + var claim_count := service.scripts.size() + service.emit_event("route_loaded", {"route": "game"}) + service.set_test_state("game", {"route": "game"}) + service.register_element("play_button", Vector2.ZERO, Vector2.ONE) + if service.scripts.size() != claim_count: + failures.append("Expected diagnostic release payloads to default deny without a policy") + if service.get_element_map().get_element_count() != 0: + failures.append("Expected diagnostic release element keys to default deny") + service._cleanup_browser_bridge() + service.free() + +func _test_production_payload_policy_allows_only_declared_safe_fields(failures: Array[String]) -> void: + var policy := PlaywrightServiceModule.PlaywrightPayloadPolicy.new( + PackedStringArray(["play_button"]), + PackedStringArray(["enemy_"]), + {"route_loaded": PackedStringArray(["route"])}, + {"game": PackedStringArray(["route", "units"])} + ) + var service := RecordingWebService.new() + service.configure(PlaywrightServiceModule.PlaywrightConfig.new(true, false, false, 1000, 500, policy)) + service.emit_event("route_loaded", {"route": "game"}) + var after_allowed_event := service.scripts.size() + service.emit_event("route_loaded", {"route": "game", "token": "must-not-publish"}) + if service.scripts.size() != after_allowed_event: + failures.append("Expected sensitive event payload to be rejected before browser publication") + service.set_test_state("game", {"route": "game", "units": [{"id": 1}]}) + var after_allowed_state := service.scripts.size() + service.set_test_state("game", {"route": "game", "units": [{"session": "must-not-publish"}]}) + if service.scripts.size() != after_allowed_state: + failures.append("Expected nested sensitive state key to be rejected before publication") + service.register_element("play_button", Vector2.ZERO, Vector2.ONE) + service.register_element("enemy_7", Vector2.ZERO, Vector2.ONE) + service.register_element("private_admin", Vector2.ZERO, Vector2.ONE) + if service.get_element_map().get_element_count() != 2: + failures.append("Expected only exact/prefix allowlisted element keys") + service._cleanup_browser_bridge() + service.free() + +func _test_ordinary_release_cannot_enable_bridge_by_setting(failures: Array[String]) -> void: + var service := OrdinaryWebService.new() + service.configure(PlaywrightServiceModule.PlaywrightConfig.new(true, true, false)) + service.emit_event("route_loaded", {"route": "game"}) + if not service.scripts.is_empty(): + failures.append("Expected ordinary release artifact to ignore enabled/test_mode settings") + service.free() diff --git a/gd/tests/web_export_test.sh b/gd/tests/web_export_test.sh new file mode 100755 index 0000000..4ef86f5 --- /dev/null +++ b/gd/tests/web_export_test.sh @@ -0,0 +1,248 @@ +#!/bin/bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 ADDON_ZIP" >&2 + exit 2 +fi +GODOT="${GODOT_BIN:-godot}" +PLAYWRIGHT="${PLAYWRIGHT_CLI_BIN:-playwright-cli}" +ARCHIVE="$1" +OUTPUT_DIR="${GD_WEB_OUTPUT_DIR:-$(mktemp -d)}" +TEMP="${GD_WEB_TEMP_DIR:-$(mktemp -d)}" +mkdir -p "$TEMP" +SERVER_PID="" +SESSION="gd-playwright-web-$$" +cleanup() { + "$PLAYWRIGHT" -s="$SESSION" close >/dev/null 2>&1 || true + if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" >/dev/null 2>&1 || true; fi + if [ -z "${GD_WEB_TEMP_DIR:-}" ]; then rm -rf "$TEMP"; fi + if [ -z "${GD_WEB_OUTPUT_DIR:-}" ]; then rm -rf "$OUTPUT_DIR"; fi +} +trap cleanup EXIT +PROJECT="$TEMP/project" +mkdir -p "$PROJECT/addons/@aviorstudio_gd-playwright" "$OUTPUT_DIR/ordinary" "$OUTPUT_DIR/diagnostic" +python3 - "$ARCHIVE" "$PROJECT/addons/@aviorstudio_gd-playwright" <<'PY' +from pathlib import Path +import sys +import zipfile +with zipfile.ZipFile(Path(sys.argv[1])) as package: + package.extractall(Path(sys.argv[2])) +PY +cat > "$PROJECT/project.godot" <<'EOF' +config_version=5 + +[application] +config/name="gd-playwright web identity fixture" +run/main_scene="res://main.tscn" + +[autoload] +PlaywrightService="*res://addons/@aviorstudio_gd-playwright/autoload.gd" + +[display] +window/size/viewport_width=640 +window/size/viewport_height=360 +window/size/window_width_override=640 +window/size/window_height_override=360 + +[rendering] +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +EOF +cat > "$PROJECT/main.tscn" <<'EOF' +[gd_scene load_steps=2 format=3] + +[ext_resource path="res://main.gd" type="Script" id="1"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Title" type="Label" parent="."] +offset_left = 40.0 +offset_top = 40.0 +offset_right = 600.0 +offset_bottom = 80.0 +text = "gd-playwright export identity" + +[node name="Identity" type="Label" parent="."] +offset_left = 40.0 +offset_top = 100.0 +offset_right = 600.0 +offset_bottom = 140.0 + +[node name="Contract" type="Label" parent="."] +offset_left = 40.0 +offset_top = 160.0 +offset_right = 600.0 +offset_bottom = 220.0 + +[node name="StartButton" type="Button" parent="."] +offset_left = 40.0 +offset_top = 250.0 +offset_right = 240.0 +offset_bottom = 310.0 +text = "Normal input target" +EOF +cat > "$PROJECT/main.gd" <<'EOF' +extends Control + +const ServiceModule = preload("res://addons/@aviorstudio_gd-playwright/src/playwright_service.gd") + +func _ready() -> void: + var diagnostic := OS.has_feature(ServiceModule.DIAGNOSTICS_EXPORT_FEATURE) + $Identity.text = "Artifact: " + ("DIAGNOSTIC" if diagnostic else "ORDINARY") + if not diagnostic: + $Contract.text = "Bridge globals: ABSENT (settings cannot enable them)" + PlaywrightService.configure(ServiceModule.PlaywrightConfig.new(true, true, false)) + PlaywrightService.emit_event("route_loaded", {"route": "ordinary"}) + return + + var policy := ServiceModule.PlaywrightPayloadPolicy.new( + PackedStringArray(["start_button"]), + PackedStringArray(), + {"route_loaded": PackedStringArray(["route"])}, + {"fixture": PackedStringArray(["route", "cleanup", "input"])} + ) + var config := ServiceModule.PlaywrightConfig.new(true, false, false, 100, 50, policy) + PlaywrightService.configure(config) + PlaywrightService.register_element("start_button", Vector2(140, 280), Vector2(200, 60), true) + PlaywrightService.register_element("private_admin", Vector2.ZERO, Vector2.ONE, true) + PlaywrightService.emit_event("route_loaded", {"route": "diagnostic"}) + PlaywrightService.emit_event("route_loaded", {"route": "rejected", "token": "never-published"}) + PlaywrightService.set_test_state("fixture", {"route": "diagnostic", "cleanup": false, "input": false}) + PlaywrightService.set_test_state("fixture", {"route": "rejected", "token": "never-published"}) + await get_tree().process_frame + + var stale := ServiceModule.new() + get_tree().root.add_child(stale) + stale.configure(config) + stale.set_test_state("fixture", {"route": "stale", "cleanup": false, "input": false}) + PlaywrightService.set_test_state("fixture", {"route": "diagnostic", "cleanup": false, "input": false}) + stale.free() + var isolated := bool(JavaScriptBridge.eval("window.godotTestState?.fixture?.route === 'diagnostic'")) + + var disposable := ServiceModule.new() + get_tree().root.add_child(disposable) + disposable.configure(config) + disposable.set_test_state("fixture", {"route": "disposable", "cleanup": false, "input": false}) + disposable.free() + var cleaned := bool(JavaScriptBridge.eval("window.__gdPlaywrightOwner === undefined && window.godotTestState === undefined")) + + PlaywrightService.configure(config) + PlaywrightService.register_element("start_button", Vector2(140, 280), Vector2(200, 60), true) + PlaywrightService.emit_event("route_loaded", {"route": "diagnostic"}) + PlaywrightService.set_test_state("fixture", {"route": "diagnostic", "cleanup": cleaned and isolated, "input": false}) + $Contract.text = "Read-only allowlist: PASS | owner cleanup/isolation: " + ("PASS" if cleaned and isolated else "FAIL") + $StartButton.pressed.connect(_on_start_button_pressed) + +func _on_start_button_pressed() -> void: + PlaywrightService.set_test_state("fixture", {"route": "diagnostic", "cleanup": true, "input": true}) + $StartButton.text = "Normal input received" +EOF +cat > "$PROJECT/export_presets.cfg" <<'EOF' +[preset.0] +name="Ordinary" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +progressive_web_app/enabled=false + +[preset.1] +name="Diagnostic" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="gd_playwright_diagnostics" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +progressive_web_app/enabled=false +EOF + +"$GODOT" --headless --path "$PROJECT" --export-release Ordinary "$OUTPUT_DIR/ordinary/index.html" +"$GODOT" --headless --path "$PROJECT" --export-release Diagnostic "$OUTPUT_DIR/diagnostic/index.html" + +python3 - "$OUTPUT_DIR" <<'PY' +from pathlib import Path +import hashlib +import sys + +root = Path(sys.argv[1]) +lines = [] +digests = {} +for identity in ("ordinary", "diagnostic"): + tree = hashlib.sha256() + files = sorted(path for path in (root / identity).rglob("*") if path.is_file()) + if not files: + raise SystemExit(f"{identity} export is empty") + for path in files: + name = path.relative_to(root / identity).as_posix() + tree.update(name.encode() + b"\0" + hashlib.sha256(path.read_bytes()).digest()) + digests[identity] = tree.hexdigest() + lines.append(f"GD_WEB_{identity.upper()}_TREE_SHA256={digests[identity]}") +if digests["ordinary"] == digests["diagnostic"]: + raise SystemExit("ordinary and diagnostic artifact identities unexpectedly match") +(root / "web-artifact-identities.txt").write_text("\n".join(lines) + "\n") +print("\n".join(lines)) +PY + +PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') +python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$OUTPUT_DIR" >"$TEMP/server.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:$PORT/ordinary/index.html" >/dev/null; then break; fi + sleep 1 +done + +"$PLAYWRIGHT" -s="$SESSION" open "http://127.0.0.1:$PORT/ordinary/index.html" +"$PLAYWRIGHT" -s="$SESSION" run-code "async page => { await page.waitForTimeout(2000); if (!await page.locator('canvas').isVisible()) throw new Error('ordinary artifact canvas was not visible'); if (await page.evaluate(() => ['godotElements','godotEvents','godotTestState','__gdPlaywrightOwner'].some(k => window[k] !== undefined))) throw new Error('ordinary artifact exposed bridge globals'); }" +"$PLAYWRIGHT" -s="$SESSION" screenshot --filename="$OUTPUT_DIR/ordinary.png" +"$PLAYWRIGHT" -s="$SESSION" goto "http://127.0.0.1:$PORT/diagnostic/index.html" +"$PLAYWRIGHT" -s="$SESSION" run-code "async page => { await page.waitForFunction(() => window.godotTestState?.fixture?.cleanup === true); const result = await page.evaluate(() => ({ keys: Object.keys(window.godotElements || {}), state: window.godotTestState?.fixture, events: window.godotEvents || [] })); if (JSON.stringify(result.keys) !== JSON.stringify(['start_button'])) throw new Error('element allowlist failed: ' + JSON.stringify(result)); if (result.state?.route !== 'diagnostic' || result.state?.cleanup !== true || 'token' in result.state) throw new Error('state policy failed: ' + JSON.stringify(result)); if (result.events.length !== 1 || result.events[0].data.route !== 'diagnostic' || 'token' in result.events[0].data) throw new Error('event policy failed: ' + JSON.stringify(result)); const point = await page.evaluate(() => { const e = window.godotElements.start_button, v = window.godotElementsViewport, r = document.querySelector('canvas').getBoundingClientRect(); return {x: r.x + e.x * r.width / v.width, y: r.y + e.y * r.height / v.height}; }); await page.mouse.click(point.x, point.y); await page.waitForFunction(() => window.godotTestState.fixture.input === true); }" +"$PLAYWRIGHT" -s="$SESSION" screenshot --filename="$OUTPUT_DIR/diagnostic.png" +"$PLAYWRIGHT" -s="$SESSION" close +echo "ASSERTION_REACHED ordinary_diagnostic_web_artifacts_and_cleanup"