Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion gd/addon/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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"
38 changes: 36 additions & 2 deletions gd/addon/src/element_map_service.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
77 changes: 63 additions & 14 deletions gd/addon/src/playwright_service.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions gd/tests/element_map_service_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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")
29 changes: 29 additions & 0 deletions gd/tests/playwright_service_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Loading