From b2b1bf88695a41366c579e5a38b7285f8341e823 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 12 Sep 2026 14:35:18 -0600 Subject: [PATCH 1/2] Isolate browser bridge cleanup --- gd/addon/src/element_map_service.gd | 5 ++- gd/addon/src/playwright_service.gd | 64 ++++++++++++++++++++++++++--- gd/tests/playwright_service_test.gd | 50 ++++++++++++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/gd/addon/src/element_map_service.gd b/gd/addon/src/element_map_service.gd index e6cc583..9d683d7 100644 --- a/gd/addon/src/element_map_service.gd +++ b/gd/addon/src/element_map_service.gd @@ -144,7 +144,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..05a0a5a 100644 --- a/gd/addon/src/playwright_service.gd +++ b/gd/addon/src/playwright_service.gd @@ -45,21 +45,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") @@ -126,7 +134,8 @@ func set_test_state(state_namespace_name: String, state: Dictionary) -> void: 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 +146,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 +155,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") @@ -209,7 +220,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,10 +241,11 @@ 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() @@ -244,6 +257,45 @@ func _should_emit_events() -> bool: return OS.is_debug_build() +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_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..eae91b0 100644 --- a/gd/tests/playwright_service_test.gd +++ b/gd/tests/playwright_service_test.gd @@ -12,12 +12,24 @@ 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 _browser_eval(code: String) -> Variant: + scripts.append(code) + return null + 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) if failures.is_empty(): print("PASS gd-playwright playwright_service_test") @@ -72,3 +84,41 @@ 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() From a8b89a1f705ccca82cea27087600ad5b82ad49ba Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 12 Sep 2026 19:07:07 -0600 Subject: [PATCH 2/2] Gate production diagnostics exports --- .github/actions/test/action.yml | 43 ++- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 2 +- README.md | 21 +- .../evidence/issue-148/diagnostic-release.png | Bin 0 -> 18790 bytes docs/evidence/issue-148/ordinary-release.png | Bin 0 -> 18283 bytes gd/addon/src/element_map_service.gd | 2 + gd/addon/src/playwright_service.gd | 129 ++++++++- gd/tests/playwright_service_test.gd | 62 +++++ gd/tests/web_export_test.sh | 248 ++++++++++++++++++ 10 files changed, 496 insertions(+), 15 deletions(-) create mode 100644 docs/evidence/issue-148/diagnostic-release.png create mode 100644 docs/evidence/issue-148/ordinary-release.png create mode 100755 gd/tests/web_export_test.sh 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 0000000000000000000000000000000000000000..d41bfadfe03a18a5e2841b058a2baf8bb68bd4c8 GIT binary patch literal 18790 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 z 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 FzW`@G+&TaN literal 0 HcmV?d00001 diff --git a/docs/evidence/issue-148/ordinary-release.png b/docs/evidence/issue-148/ordinary-release.png new file mode 100644 index 0000000000000000000000000000000000000000..ea0f4ecb6cdae386dd6cb2c95653ed56298cfffa GIT binary patch literal 18283 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

    yAqRU+*S>sb;Rdc3iP$ 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 USA#wLi;(_BlRt{jU;X=k054q#O#lD@ literal 0 HcmV?d00001 diff --git a/gd/addon/src/element_map_service.gd b/gd/addon/src/element_map_service.gd index 9d683d7..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), diff --git a/gd/addon/src/playwright_service.gd b/gd/addon/src/playwright_service.gd index 05a0a5a..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/" @@ -85,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 @@ -132,6 +226,8 @@ 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) _claim_browser_bridge() @@ -209,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, @@ -248,14 +346,11 @@ func _should_emit_events() -> bool: if not _is_web_runtime(): return false var config: PlaywrightConfig = _resolve_config() - - if _is_test_mode_enabled(): + if _is_debug_runtime(): return true - - if config.enabled: - return true - - return OS.is_debug_build() + if not _has_diagnostics_export_feature(): + return false + return _is_test_mode_enabled() or config.enabled func _exit_tree() -> void: _cleanup_browser_bridge() @@ -296,6 +391,24 @@ func _browser_eval(code: String) -> Variant: 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 eae91b0..e36176c 100644 --- a/gd/tests/playwright_service_test.gd +++ b/gd/tests/playwright_service_test.gd @@ -18,10 +18,20 @@ class RecordingWebService extends PlaywrightServiceModule: 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) @@ -30,6 +40,9 @@ func _initialize() -> void: _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") @@ -122,3 +135,52 @@ func _test_stale_instance_cleanup_cannot_claim_another_owner(failures: Array[Str 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"