diff --git a/README.md b/README.md index e9544aa..5aa746e 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ The Godot addon writes generic browser globals during enabled web runs: - 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. -- Validation walks each accepted payload once, and enabled browser publication reuses one fixed receiver per owner. These are internal safeguards only: publication remains synchronous, events retain order/detail, and element updates still replace the full map at the existing cadence. +- Validation walks each accepted payload once, handles primitive leaves without per-value frame dictionaries, and reuses repeated key classifications only for that publication. Element publication reuses internal wire views only while each entry's public fields remain unchanged; it still serializes a fresh full-map payload before publication. Enabled browser publication also reuses one fixed receiver per owner. These are internal safeguards only: publication remains synchronous, events retain order/detail, and element updates still replace the full map at the existing cadence. - 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`. diff --git a/gd/addon/plugin.cfg b/gd/addon/plugin.cfg index 2136ec2..5268612 100644 --- a/gd/addon/plugin.cfg +++ b/gd/addon/plugin.cfg @@ -2,5 +2,5 @@ name="GD Playwright" description="Godot web test bridge for Playwright — event emission and coordinate-free element map." author="Avior Studio" -version="0.0.6" +version="0.0.7" script="plugin.gd" diff --git a/gd/addon/src/element_map_service.gd b/gd/addon/src/element_map_service.gd index fcbf073..58716fe 100644 --- a/gd/addon/src/element_map_service.gd +++ b/gd/addon/src/element_map_service.gd @@ -42,6 +42,9 @@ var _elements: Dictionary[String, ElementEntry] = {} var _dirty: bool = false var _flush_scheduled: bool = false var _owner: Node = null +var _wire_entries: Dictionary[String, ElementEntry] = {} +var _wire_views: Dictionary[String, Dictionary] = {} +var _wire_materialization_count: int = 0 ## Binds to an owner node for deferred flush scheduling. func setup(owner: Node) -> void: @@ -69,6 +72,8 @@ func unregister(key: String) -> void: if not _elements.has(key): return _elements.erase(key) + _wire_entries.erase(key) + _wire_views.erase(key) _mark_dirty() ## Updates the position and visibility of an existing element. @@ -121,12 +126,22 @@ func is_dirty() -> bool: func flush_to_browser() -> void: _flush_scheduled = false _dirty = false - if not OS.has_feature("web"): + if not _is_web_runtime(): return var elements_dict: Dictionary[String, Variant] = {} for key: String in _elements: var entry: ElementEntry = _elements[key] - elements_dict[key] = entry.to_dict() + var wire_view: Dictionary = _wire_views.get(key, {}) + if _wire_entries.get(key) != entry or not _can_reuse_wire_view(wire_view, entry): + wire_view = entry.to_dict() + _wire_materialization_count += 1 + _wire_entries[key] = entry + _wire_views[key] = wire_view + elements_dict[key] = wire_view + for cached_key: String in _wire_views.keys(): + if not _elements.has(cached_key): + _wire_views.erase(cached_key) + _wire_entries.erase(cached_key) var viewport_size: Vector2 = Vector2.ZERO var tree: SceneTree = Engine.get_main_loop() as SceneTree if tree and tree.root: @@ -137,6 +152,23 @@ func flush_to_browser() -> void: "viewport_height": int(viewport_size.y) } var json_string: String = JSON.stringify(payload) + _publish_payload_json(json_string) + +func _wire_view_matches_entry(wire_view: Dictionary, entry: ElementEntry) -> bool: + return wire_view.size() == 5 \ + and wire_view.get("x") == entry.center_x \ + and wire_view.get("y") == entry.center_y \ + and wire_view.get("w") == entry.width \ + and wire_view.get("h") == entry.height \ + and wire_view.get("visible") == entry.visible + +func _can_reuse_wire_view(wire_view: Dictionary, entry: ElementEntry) -> bool: + return _wire_view_matches_entry(wire_view, entry) + +func _is_web_runtime() -> bool: + return OS.has_feature("web") + +func _publish_payload_json(json_string: String) -> void: if _owner != null and _owner.has_method("_publish_element_map"): _owner.call("_publish_element_map", json_string) else: @@ -150,6 +182,8 @@ func flush_to_browser() -> void: ## Clears all registered elements. func clear() -> void: _elements.clear() + _wire_entries.clear() + _wire_views.clear() _mark_dirty() func _mark_dirty() -> void: diff --git a/gd/addon/src/playwright_service.gd b/gd/addon/src/playwright_service.gd index 0fa425a..b4c10ae 100644 --- a/gd/addon/src/playwright_service.gd +++ b/gd/addon/src/playwright_service.gd @@ -23,6 +23,8 @@ class PlaywrightPayloadPolicy extends RefCounted: var event_fields: Dictionary = {} var state_fields: Dictionary = {} var validation_visit_count: int = 0 + var _validation_value_frame_count: int = 0 + var _validation_key_normalization_count: int = 0 func _init( allowed_element_keys: PackedStringArray = PackedStringArray(), @@ -62,39 +64,86 @@ class PlaywrightPayloadPolicy extends RefCounted: ## Active-container tracking rejects cycles without imposing a depth limit on ## finite JSON payloads. func _is_safe_json_payload(root: Variant) -> bool: - validation_visit_count = 0 - var stack: Array[Dictionary] = [{"value": root, "leaving": false}] + var visit_count: int = 0 + var value_frame_count: int = 1 + var key_normalization_count: int = 0 + var key_classification_cache: Dictionary[String, bool] = {} + var stack: Array[Variant] = [root] + var leaving_stack := PackedByteArray([0]) var active_containers: Array[Variant] = [] while not stack.is_empty(): - var frame: Dictionary = stack.pop_back() - if bool(frame["leaving"]): + var value: Variant = stack.pop_back() + var leaving: bool = bool(leaving_stack[-1]) + leaving_stack.resize(leaving_stack.size() - 1) + if leaving: active_containers.pop_back() continue - var value: Variant = frame["value"] - validation_visit_count += 1 + visit_count += 1 if value == null or value is bool or value is String or value is int: continue if value is float: if not is_finite(value): - return false + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) continue if not (value is Array or value is Dictionary): - return false + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) for active: Variant in active_containers: if is_same(value, active): - return false + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) active_containers.append(value) - stack.append({"value": null, "leaving": true}) + stack.append(value) + leaving_stack.append(1) if value is Dictionary: for key: Variant in value: - if not (key is String) or str(key).to_snake_case().to_lower() in SENSITIVE_KEYS: - return false - stack.append({"value": value[key], "leaving": false}) + if not (key is String): + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) + var key_string: String = key + var is_sensitive: bool + if _uses_key_classification_cache() and key_classification_cache.has(key_string): + is_sensitive = key_classification_cache[key_string] + else: + is_sensitive = key_string.to_snake_case().to_lower() in SENSITIVE_KEYS + key_normalization_count += 1 + if _uses_key_classification_cache(): + key_classification_cache[key_string] = is_sensitive + if is_sensitive: + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) + var child: Variant = value[key] + if _uses_primitive_leaf_fast_path() and _is_json_primitive(child): + visit_count += 1 + if child is float and not is_finite(child): + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) + else: + stack.append(child) + leaving_stack.append(0) + value_frame_count += 1 else: for item: Variant in value: - stack.append({"value": item, "leaving": false}) + if _uses_primitive_leaf_fast_path() and _is_json_primitive(item): + visit_count += 1 + if item is float and not is_finite(item): + return _finish_validation(false, visit_count, value_frame_count, key_normalization_count) + else: + stack.append(item) + leaving_stack.append(0) + value_frame_count += 1 + return _finish_validation(true, visit_count, value_frame_count, key_normalization_count) + + func _is_json_primitive(value: Variant) -> bool: + return value == null or value is bool or value is String or value is int or value is float + + func _uses_primitive_leaf_fast_path() -> bool: + return true + + func _uses_key_classification_cache() -> bool: return true + func _finish_validation(result: bool, visits: int, value_frames: int, normalizations: int) -> bool: + validation_visit_count = visits + _validation_value_frame_count = value_frames + _validation_key_normalization_count = normalizations + return result + func _as_string_array(value: Variant) -> PackedStringArray: if value is PackedStringArray: return value diff --git a/gd/tests/element_map_service_test.gd b/gd/tests/element_map_service_test.gd index aa35c09..7a02fe3 100644 --- a/gd/tests/element_map_service_test.gd +++ b/gd/tests/element_map_service_test.gd @@ -7,6 +7,19 @@ class EnabledPlaywrightService extends PlaywrightServiceModule: func _should_emit_events() -> bool: return true +class RecordingElementMapService extends ElementMapService: + var payloads: Array[String] = [] + + func _is_web_runtime() -> bool: + return true + + func _publish_payload_json(json_string: String) -> void: + payloads.append(json_string) + +class WireCacheMutationService extends RecordingElementMapService: + func _can_reuse_wire_view(_wire_view: Dictionary, _entry: ElementEntry) -> bool: + return false + func _initialize() -> void: var failures: Array[String] = [] _test_register_and_lookup(failures) @@ -16,6 +29,7 @@ func _initialize() -> void: _test_empty_key_rejected(failures) _test_get_all_keys(failures) _test_service_register_element_api(failures) + _test_cached_wire_views_preserve_public_mutation_semantics(failures) if failures.is_empty(): print("PASS gd-playwright element_map_service_test") @@ -156,3 +170,40 @@ func _test_service_register_element_api(failures: Array[String]) -> void: if element_map != null and element_map.get_element_count() != 0: failures.append("Expected clear_elements to remove all keys") service.free() + +func _test_cached_wire_views_preserve_public_mutation_semantics(failures: Array[String]) -> void: + var service := RecordingElementMapService.new() + service.register("outer", Vector2(1, 2), Vector2(3, 4), true) + var original: ElementMapService.ElementEntry = service.get_entry("outer") + var first_copy := original.to_dict() + service.flush_to_browser() + var first_json := service.payloads[-1] + var first_wire: Dictionary = service._wire_views["outer"] + service.flush_to_browser() + if not is_same(service._wire_views["outer"], first_wire) or service._wire_materialization_count != 1: + failures.append("Expected unchanged entry to reuse its internal wire view") + var cache_mutation := WireCacheMutationService.new() + cache_mutation.register("outer", Vector2(1, 2), Vector2(3, 4), true) + cache_mutation.flush_to_browser() + cache_mutation.flush_to_browser() + if cache_mutation._wire_materialization_count <= service._wire_materialization_count: + failures.append("Expected disabling wire-view reuse to fail the materialization work invariant") + original.center_x = 9 + original.key = "inner-does-not-replace-outer" + service.flush_to_browser() + var mutated_payload: Dictionary = JSON.parse_string(service.payloads[-1]) + if int(mutated_payload["elements"]["outer"]["x"]) != 9 or mutated_payload["elements"].has(original.key): + failures.append("Expected direct entry mutation on the existing outer map key in the next explicit flush") + if int(first_copy["x"]) != 1 or first_json != service.payloads[0]: + failures.append("Expected fresh to_dict copies and retained published JSON snapshots to remain independent") + var replacement := ElementMapService.ElementEntry.new("different-inner", 11, 12, 13, 14, false) + service.get_all_entries()["outer"] = replacement + service.flush_to_browser() + var replacement_payload: Dictionary = JSON.parse_string(service.payloads[-1]) + if int(replacement_payload["elements"]["outer"]["x"]) != 11 or bool(replacement_payload["elements"]["outer"]["visible"]): + failures.append("Expected direct replacement to refresh the outer-key wire view") + service.get_all_entries().erase("outer") + service.flush_to_browser() + var removed_payload: Dictionary = JSON.parse_string(service.payloads[-1]) + if removed_payload["elements"].has("outer") or service._wire_views.has("outer") or service._wire_entries.has("outer"): + failures.append("Expected direct removal to publish and evict bounded internal cache state") diff --git a/gd/tests/playwright_service_test.gd b/gd/tests/playwright_service_test.gd index 573bb82..caa6e0b 100644 --- a/gd/tests/playwright_service_test.gd +++ b/gd/tests/playwright_service_test.gd @@ -62,6 +62,14 @@ class OrdinaryWebService extends RecordingWebService: func _has_diagnostics_export_feature() -> bool: return false +class PrimitiveFrameMutationPolicy extends PlaywrightServiceModule.PlaywrightPayloadPolicy: + func _uses_primitive_leaf_fast_path() -> bool: + return false + +class KeyCacheMutationPolicy extends PlaywrightServiceModule.PlaywrightPayloadPolicy: + func _uses_key_classification_cache() -> bool: + return false + func _initialize() -> void: var failures: Array[String] = [] _test_emit_event_delegates_to_browser_emitter(failures) @@ -73,6 +81,7 @@ func _initialize() -> void: _test_production_payload_policy_is_default_deny(failures) _test_production_payload_policy_allows_only_declared_safe_fields(failures) _test_payload_policy_differential_corpus_and_single_traversal(failures) + _test_payload_policy_work_controls(failures) _test_browser_receiver_is_cached_per_owner(failures) _test_ordinary_release_cannot_enable_bridge_by_setting(failures) @@ -252,6 +261,26 @@ func _test_payload_policy_differential_corpus_and_single_traversal(failures: Arr failures.append("Expected merged validator visit count below duplicate legacy traversals") unsupported.free() +func _test_payload_policy_work_controls(failures: Array[String]) -> void: + var allowed := {"game": PackedStringArray(["units"])} + var payload := {"units": [{"unitId": 1, "stats": {"unitId": 2}}, {"unitId": 3}]} + var policy := PlaywrightServiceModule.PlaywrightPayloadPolicy.new(PackedStringArray(), PackedStringArray(), {}, allowed) + var primitive_mutation := PrimitiveFrameMutationPolicy.new(PackedStringArray(), PackedStringArray(), {}, allowed) + var cache_mutation := KeyCacheMutationPolicy.new(PackedStringArray(), PackedStringArray(), {}, allowed) + if not policy.allows_state("game", payload) or not primitive_mutation.allows_state("game", payload) or not cache_mutation.allows_state("game", payload): + failures.append("Expected work-control policies to retain the accepted payload decision") + if policy.validation_visit_count != primitive_mutation.validation_visit_count: + failures.append("Expected primitive fast path to retain public completed-call visit count") + if policy._validation_value_frame_count >= primitive_mutation._validation_value_frame_count: + failures.append("Expected disabling primitive fast path to fail the value-frame work invariant") + if policy._validation_key_normalization_count >= cache_mutation._validation_key_normalization_count: + failures.append("Expected disabling publication-local key cache to fail the normalization work invariant") + var sensitive := {"units": [{"refreshToken": "reject"}]} + if policy.allows_state("game", sensitive): + failures.append("Expected normalized sensitive key to remain rejected") + if policy.validation_visit_count != 3: + failures.append("Expected early rejection to publish one completed-call visit count") + func _legacy_allows_dictionary(payload: Dictionary, allowed_fields: PackedStringArray, visits: Array[int]) -> bool: if not _legacy_contains_no_sensitive_key(payload, visits): return false