first commit

This commit is contained in:
Leonid Pershin
2026-08-10 20:39:06 +03:00
commit f16f60445c
281 changed files with 43576 additions and 0 deletions
@@ -0,0 +1,49 @@
@tool
extends Logger
## Captures GDScript runtime errors emitted while a test is running.
##
## Deliberately no class_name: this is an internal test helper.
##
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
## should not abort the test.
var _mutex := Mutex.new()
var _capturing := false
var _errors := PackedStringArray()
func begin_capture() -> void:
_mutex.lock()
_capturing = true
_errors.clear()
_mutex.unlock()
func end_capture() -> PackedStringArray:
_mutex.lock()
var captured := _errors.duplicate()
_capturing = false
_errors.clear()
_mutex.unlock()
return captured
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
_script_backtraces: Array,
) -> void:
if error_type != ERROR_TYPE_SCRIPT:
return
_mutex.lock()
if _capturing:
var text := rationale if not rationale.is_empty() else code
_errors.append("%s (%s:%d in %s)" % [text, file, line, function])
_mutex.unlock()
@@ -0,0 +1 @@
uid://crk8w2nei087v
+441
View File
@@ -0,0 +1,441 @@
@tool
class_name McpTestRunner
extends RefCounted
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
## on McpTestSuite instances, runs them, and collects structured results.
const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd")
var _results: Array[Dictionary] = []
var _last_run_ms: int = 0
var _script_error_capture: ScriptErrorCapture = null
var _capture_registered := false
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
OS.remove_logger(_script_error_capture)
_capture_registered = false
func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
var owns_capture := not _capture_registered
if owns_capture:
_register_capture()
_run_suite_tests(suite, test_filter, exclude_test_filter, Callable(), 0, {})
if owns_capture:
_unregister_capture()
## Shared per-test loop for both the legacy synchronous path and the
## serviced driver. Returns "" when the loop completed, or a terminal
## outcome ("timeout" / "transport_lost" / "paused") when a checkpoint
## aborted it. Checkpoints run BETWEEN tests — an atomic test body is
## never preempted (see docs/test-run-transport-starvation-plan.md).
func _run_suite_tests(
suite: McpTestSuite,
test_filter: String,
exclude_test_filter: String,
service_cb: Callable,
deadline_ticks_ms: int,
run_state: Dictionary,
) -> String:
var name := suite.suite_name()
var methods := _get_test_methods(suite)
var exclusions := _parse_exclusions(exclude_test_filter)
for method_name in methods:
if not test_filter.is_empty() and method_name.find(test_filter) == -1:
continue
_selected_processed += 1
if _matches_any_exclusion(method_name, exclusions):
_results.append({
"suite": name,
"test": method_name,
"passed": true,
"skipped": true,
"message": "Excluded by exclude_test_name filter",
"assertion_count": 0,
"duration_ms": 0,
})
continue
var test_start := Time.get_ticks_msec()
var entry := _run_one_test(suite, name, method_name)
entry["duration_ms"] = Time.get_ticks_msec() - test_start
_results.append(entry)
var stop := _checkpoint(service_cb, deadline_ticks_ms, run_state)
if not stop.is_empty():
return stop
return ""
## Execute one test method and return its result entry (not yet appended;
## the caller stamps `duration_ms`). Extracted from run_suite so the legacy
## and serviced drivers share one execution core — behavior must stay
## byte-identical to the pre-refactor loop body.
func _run_one_test(suite: McpTestSuite, name: String, method_name: String) -> Dictionary:
suite._reset()
_begin_script_error_capture()
suite.setup()
suite.call(method_name)
suite.teardown()
var script_errors := suite._unexpected_script_errors(_end_script_error_capture())
suite._free_tracked()
## Issue #19 defence: free any `_McpTest*` nodes the test created, even
## nested ones. If the scene gets auto-saved mid-test while one of these
## exists, the reference bakes into main.tscn and breaks the next open
## with a "missing dependency" error. Runs after every test, not just at
## suite boundaries, so a test that fails mid-flow can't leave a trap
## for the next test or for scene autosave.
var scene_root_for_cleanup := _edited_scene_root()
if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree():
_free_mcp_test_nodes_recursive(scene_root_for_cleanup)
if not script_errors.is_empty():
var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors)
if suite._failed:
abort_message += " (after assertion failure: %s)" % suite._message
return {
"suite": name,
"test": method_name,
"passed": false,
"message": abort_message,
"assertion_count": suite._assertion_count,
}
## A failed assertion always wins over a later skip(): a test that
## fails and then hits a skip-guard must report the failure, not
## park itself as green-skipped.
if suite._skipped and not suite._failed:
return {
"suite": name,
"test": method_name,
"passed": true,
"skipped": true,
"message": suite._skip_reason,
"assertion_count": 0,
}
var passed := not suite._failed
var msg := suite._message
## Warn about zero-assertion tests (likely silently skipped logic).
if passed and suite._assertion_count == 0:
passed = false
msg = "Test completed with 0 assertions (likely skipped its logic)"
return {
"suite": name,
"test": method_name,
"passed": passed,
"message": msg,
"assertion_count": suite._assertion_count,
}
func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary:
## Legacy synchronous API — unchanged signature and semantics for direct
## callers, unit-test fixtures, and any batch context. No servicing, no
## ceiling: with an invalid Callable and deadline 0 every checkpoint is a
## no-op and the outcome is always "completed".
var run := run_suites_serviced(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
return run["results"]
## Serviced driver for live MCP runs. Between tests and at suite
## boundaries it (a) aborts once `deadline_ticks_ms` passes and (b) calls
## `service_cb` (McpConnection.service_transport_during_exclusive_run) so
## the WebSocket heartbeat stays alive while the suite monopolizes the
## main thread. Returns:
## {
## "outcome": "completed" | "timeout" | "transport_lost" | "paused",
## "results": <same dict get_results(verbose) returns>,
## "tests_not_run": <int, 0 when completed>,
## }
## Every outcome path — including aborts — runs the current suite's
## teardown + leak cleanup, restores console echo, and unregisters the
## capture logger. `run_state` is caller-owned and threaded through to the
## service callback (cumulative packet counter lives there).
func run_suites_serviced(
suites: Array,
suite_filter: String = "",
test_filter: String = "",
ctx: Dictionary = {},
verbose: bool = false,
exclude_test_filter: String = "",
service_cb: Callable = Callable(),
deadline_ticks_ms: int = 0,
run_state: Dictionary = {},
) -> Dictionary:
_results.clear()
_selected_processed = 0
var selected_total := _count_selected_tests(suites, suite_filter, test_filter)
var start := Time.get_ticks_msec()
var outcome := ""
## Silence the plugin's ring-buffer console echo while tests run. Negative-
## path suites deliberately fill the ring with 500 lines and log malformed-
## result errors; echoing all of that buries an all-green run in scary
## console output. The ring contents tests assert on are untouched, and
## the flag is restored after the run so live logging resumes.
var _prev_console_echo := McpLogBuffer.console_echo
McpLogBuffer.console_echo = false
## If a prior run was interrupted after registering the logger but before
## normal teardown, remove that stale registration before starting fresh.
_unregister_capture()
_register_capture()
for suite: McpTestSuite in suites:
if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
continue
## Snapshot scene children before the suite so we can clean up leaks.
var scene_root := _edited_scene_root()
var before_children: Array[Node] = []
if scene_root != null:
before_children = _get_children_snapshot(scene_root)
suite._reset_suite_state()
suite.suite_setup(ctx.duplicate(true))
## fail_setup() / skip_suite() gives suites a clean way to bail out of
## suite_setup without leaving N tests to fail with "0 assertions". We
## emit ONE suite-level result and skip individual tests entirely.
if suite._suite_failed:
_results.append({
"suite": suite.suite_name(),
"test": "<suite_setup>",
"passed": false,
"message": "suite_setup() failed: %s (subsequent tests not run)" % suite._suite_failed_message,
"assertion_count": 0,
})
## The bailed suite's tests are accounted for, not "not run".
_selected_processed += _count_selected_tests([suite], "", test_filter)
elif suite._suite_skipped:
_results.append({
"suite": suite.suite_name(),
"test": "<suite_setup>",
"passed": true,
"skipped": true,
"message": "suite_setup() skipped: %s" % suite._suite_skipped_reason,
"assertion_count": 0,
})
_selected_processed += _count_selected_tests([suite], "", test_filter)
else:
outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state)
if outcome.is_empty():
outcome = _run_suite_tests(
suite, test_filter, exclude_test_filter,
service_cb, deadline_ticks_ms, run_state
)
## Suite epilogue runs on EVERY path, including aborts: the suite has
## begun, so its teardown and leak cleanup must not be skipped.
suite.suite_teardown()
suite._free_tracked()
## Remove any nodes the suite left behind (failed undo, missing cleanup).
if scene_root != null and scene_root.is_inside_tree():
_cleanup_leaked_nodes(scene_root, before_children)
if not outcome.is_empty():
break
outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state)
if not outcome.is_empty():
break
_last_run_ms = Time.get_ticks_msec() - start
McpLogBuffer.console_echo = _prev_console_echo
_unregister_capture()
if outcome.is_empty():
outcome = "completed"
return {
"outcome": outcome,
"results": get_results(verbose),
"tests_not_run": maxi(0, selected_total - _selected_processed),
}
## Count of selected (filter-surviving) tests already looped over this run,
## including exclusion-skips and bailed-suite accounting. Drives the
## `tests_not_run` estimate on aborted runs.
var _selected_processed := 0
func _count_selected_tests(suites: Array, suite_filter: String, test_filter: String) -> int:
var count := 0
for suite: McpTestSuite in suites:
if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
continue
for method_name in _get_test_methods(suite):
if not test_filter.is_empty() and method_name.find(test_filter) == -1:
continue
count += 1
return count
## Between-phase checkpoint: "" to continue, or a terminal outcome.
## Delegates to the shared mapping so the discovery checkpoints in
## test_handler.gd can never drift from the between-test ones (plan §9 Q2).
func _checkpoint(service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary) -> String:
return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state)
func _register_capture() -> void:
if _capture_registered:
return
if _script_error_capture == null:
_script_error_capture = ScriptErrorCapture.new()
if _script_error_capture == null:
return
OS.add_logger(_script_error_capture)
_capture_registered = true
func _unregister_capture() -> void:
if not _capture_registered:
return
if _script_error_capture == null:
_capture_registered = false
return
OS.remove_logger(_script_error_capture)
_capture_registered = false
func _begin_script_error_capture() -> void:
if _script_error_capture != null and _capture_registered:
_script_error_capture.begin_capture()
func _end_script_error_capture() -> PackedStringArray:
if _script_error_capture == null or not _capture_registered:
return PackedStringArray()
return _script_error_capture.end_capture()
static func _edited_scene_root() -> Node:
if not Engine.is_editor_hint():
return null
return EditorInterface.get_edited_scene_root()
func get_results(verbose: bool = false) -> Dictionary:
var passed := 0
var failed := 0
var skipped := 0
var failures: Array[Dictionary] = []
var suites_seen := {}
for r in _results:
suites_seen[r.suite] = true
if r.get("skipped", false):
skipped += 1
elif r.passed:
passed += 1
else:
failed += 1
failures.append(r)
var result := {
"passed": passed,
"failed": failed,
"skipped": skipped,
"total": _results.size(),
"duration_ms": _last_run_ms,
"suites_run": suites_seen.keys(),
"suite_count": suites_seen.size(),
}
if not failures.is_empty():
result["failures"] = failures
if verbose:
result["results"] = _results
return result
func clear() -> void:
_results.clear()
_last_run_ms = 0
func _get_test_methods(obj: Object) -> Array[String]:
var methods: Array[String] = []
for m in obj.get_method_list():
var name: String = m.get("name", "")
if name.begins_with("test_"):
methods.append(name)
methods.sort()
return methods
func _get_children_snapshot(node: Node) -> Array[Node]:
var children: Array[Node] = []
for child in node.get_children():
children.append(child)
return children
## Remove any nodes in scene_root that weren't present before the suite ran,
## plus any _McpTest* named nodes anywhere in the tree (catches nested leaks).
## NOTE: this bypasses EditorUndoRedoManager by design — the test runner
## owns these leaks and needs to clear them unconditionally. Don't Ctrl-Z in
## the editor immediately after a test run that triggered cleanup; the undo
## stack may reference freed nodes.
func _cleanup_leaked_nodes(scene_root: Node, before: Array[Node]) -> void:
var before_set := {}
for n in before:
before_set[n] = true
for child in scene_root.get_children():
if not before_set.has(child):
scene_root.remove_child(child)
child.queue_free()
## Recursively free every node whose name starts with `_McpTest`, anywhere in
## the scene. Intentionally bypasses undo — these are test leaks, not user
## work. Walk breadth-first so we can collect victims before mutating the tree.
func _free_mcp_test_nodes_recursive(root: Node) -> void:
var victims: Array[Node] = []
var queue: Array[Node] = [root]
while not queue.is_empty():
var node: Node = queue.pop_back()
for child in node.get_children():
if str(child.name).begins_with("_McpTest"):
victims.append(child)
else:
queue.append(child)
for v in victims:
if v.get_parent() != null:
v.get_parent().remove_child(v)
v.queue_free()
## Split the `exclude_test_name` filter into individual substring matchers.
## Comma-separated so the CI smoke harness can list multiple flaky tests
## without shipping a richer schema (single names still work — same string,
## no comma, same one-element list). Whitespace around each name is stripped
## so `"a, b"` and `"a,b"` behave identically.
static func _parse_exclusions(filter: String) -> Array[String]:
var out: Array[String] = []
if filter.is_empty():
return out
for part in filter.split(","):
var trimmed := part.strip_edges()
if not trimmed.is_empty():
out.append(trimmed)
return out
static func _matches_any_exclusion(method_name: String, exclusions: Array[String]) -> bool:
for ex in exclusions:
if method_name.find(ex) != -1:
return true
return false
@@ -0,0 +1 @@
uid://367b77qh5grt
+330
View File
@@ -0,0 +1,330 @@
@tool
class_name McpTestSuite
extends RefCounted
## Base class for MCP test suites. Provides assertion methods and
## lifecycle hooks. Subclass this, add test_* methods, and drop the
## script in res://tests/.
## Override to return a short name for this suite (e.g. "scene", "node").
func suite_name() -> String:
return "unnamed"
## Called once before the suite runs. Override to create handlers.
func suite_setup(_ctx: Dictionary) -> void:
pass
## Called before each test method.
func setup() -> void:
pass
## Called after each test method.
func teardown() -> void:
pass
## Called once after the suite finishes.
func suite_teardown() -> void:
pass
# ----- tracked allocations (freed by the runner after each test) -----
var _tracked_objects: Array[Object] = []
## Register a manually-managed Object (plain Object or out-of-tree Node) so
## the runner frees it after the current test. RefCounted instances are
## accepted but ignored because they manage their own lifetime.
func track(obj: Object) -> Object:
if obj != null and not obj is RefCounted:
_tracked_objects.append(obj)
return obj
## Free everything registered via track(). Called by the runner after each
## test's teardown() and again after suite_teardown().
func _free_tracked() -> void:
for obj in _tracked_objects:
if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()):
continue
if obj is Node:
var parent := (obj as Node).get_parent()
if parent != null:
parent.remove_child(obj)
obj.free()
_tracked_objects.clear()
# ----- assertion state (managed by McpTestRunner) -----
var _failed: bool = false
var _message: String = ""
var _assertion_count: int = 0
var _skipped: bool = false
var _skip_reason: String = ""
var _expected_script_error_substrings: Array[String] = []
# ----- suite-level state (managed by McpTestRunner) -----
var _suite_failed: bool = false
var _suite_failed_message: String = ""
var _suite_skipped: bool = false
var _suite_skipped_reason: String = ""
func _reset() -> void:
_failed = false
_message = ""
_assertion_count = 0
_skipped = false
_skip_reason = ""
_expected_script_error_substrings.clear()
func _reset_suite_state() -> void:
_suite_failed = false
_suite_failed_message = ""
_suite_skipped = false
_suite_skipped_reason = ""
## Mark the current test as skipped. Use when a precondition isn't met
## (e.g. no scene open, no Node3D in scene) and the test can't run.
## Skipped tests count separately from passed/failed.
func skip(reason: String = "") -> void:
_skipped = true
_skip_reason = reason
## Bail out of suite_setup() with a failure. Subsequent tests in this suite
## are not run; the runner reports a single suite-level failure with the
## given reason instead of N zero-assertion noise lines per test.
##
## Example:
## func suite_setup(ctx):
## var arena = preload("res://game/arena.gd").new()
## if arena == null:
## fail_setup("arena.gd failed to instantiate in @tool scope")
## return
func fail_setup(reason: String) -> void:
_suite_failed = true
_suite_failed_message = reason
## Bail out of suite_setup() because a precondition isn't met (no scene open,
## no game running, etc.). Subsequent tests are not run and the runner emits
## a single suite-level skip rather than per-test skip noise.
func skip_suite(reason: String) -> void:
_suite_skipped = true
_suite_skipped_reason = reason
## Mark the current test as skipped when the running Godot is older than
## `min_version` (a "major.minor" string like "4.6"). Use for tests that
## exercise an engine API or behavior that only exists on newer Godot.
## Returns true when the test was skipped, so callers can `return` from
## the test body.
##
## Example:
## func test_uses_46_only_api() -> void:
## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"):
## return
## ...
func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
var v := Engine.get_version_info()
var current_major := int(v.get("major", 0))
var current_minor := int(v.get("minor", 0))
var parts := min_version.split(".")
var want_major := int(parts[0]) if parts.size() > 0 else 0
var want_minor := int(parts[1]) if parts.size() > 1 else 0
if (
current_major < want_major
or (current_major == want_major and current_minor < want_minor)
):
var msg := reason if not reason.is_empty() else "requires Godot %s+" % min_version
skip(msg + " (running %d.%d)" % [current_major, current_minor])
return true
return false
## Allow one captured SCRIPT ERROR whose text contains `substring`.
## Use only for negative-path tests that intentionally compile or execute
## invalid GDScript and assert on the resulting diagnostics.
func expect_script_error_containing(substring: String) -> void:
_expected_script_error_substrings.append(substring)
func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray:
var unexpected := PackedStringArray()
var remaining := _expected_script_error_substrings.duplicate()
for error in captured:
var matched_index := -1
for i in range(remaining.size()):
if error.find(remaining[i]) != -1:
matched_index = i
break
if matched_index == -1:
unexpected.append(error)
else:
remaining.remove_at(matched_index)
return unexpected
## Trigger an undo against whichever history (scene or global) holds the most
## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()`
## directly — you resolve the history's underlying UndoRedo and call it there.
## Actions registered via `add_do_method(self, …)` with a non-scene target land
## in GLOBAL_HISTORY, while actions on scene nodes land in the scene's history,
## so we try both (matches the pattern in batch_handler.gd).
func editor_undo(undo_redo: EditorUndoRedoManager) -> bool:
for ur in _collect_histories(undo_redo):
if ur.undo():
return true
return false
## Mirror of `editor_undo` for redo.
func editor_redo(undo_redo: EditorUndoRedoManager) -> bool:
for ur in _collect_histories(undo_redo):
if ur.redo():
return true
return false
func _collect_histories(undo_redo: EditorUndoRedoManager) -> Array:
var out: Array = []
if undo_redo == null:
return out
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root != null:
var scene_id := undo_redo.get_object_history_id(scene_root)
var scene_ur := undo_redo.get_history_undo_redo(scene_id)
if scene_ur != null:
out.append(scene_ur)
var global_ur := undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY)
if global_ur != null and not global_ur in out:
out.append(global_ur)
return out
# ----- assertions -----
func assert_true(condition: bool, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if not condition:
_failed = true
_message = msg if msg else "Expected true"
func assert_false(condition: bool, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if condition:
_failed = true
_message = msg if msg else "Expected false"
func assert_eq(actual: Variant, expected: Variant, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if actual != expected:
_failed = true
_message = msg if msg else "Expected %s, got %s" % [str(expected), str(actual)]
func assert_ne(actual: Variant, not_expected: Variant, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if actual == not_expected:
_failed = true
_message = msg if msg else "Expected value != %s" % str(not_expected)
func assert_gt(actual: Variant, threshold: Variant, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if not (actual > threshold):
_failed = true
_message = msg if msg else "Expected %s > %s" % [str(actual), str(threshold)]
func assert_has_key(dict: Variant, key: String, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if not dict is Dictionary:
_failed = true
_message = msg if msg else "Expected Dictionary, got %s" % type_string(typeof(dict))
return
if not dict.has(key):
_failed = true
_message = msg if msg else "Missing key: %s (keys: %s)" % [key, str(dict.keys())]
func assert_contains(haystack: Variant, needle: Variant, msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if haystack is String:
if haystack.find(str(needle)) == -1:
_failed = true
_message = msg if msg else "'%s' not found in '%s'" % [str(needle), haystack]
elif haystack is Array:
if not haystack.has(needle):
_failed = true
_message = msg if msg else "%s not found in array" % str(needle)
else:
_failed = true
_message = msg if msg else "assert_contains requires String or Array"
func assert_is_error(result: Dictionary, expected_code: String = "", msg: String = "") -> void:
_assertion_count += 1
if _failed:
return
if not result.has("error"):
_failed = true
_message = msg if msg else "Expected error response, got: %s" % str(result.keys())
return
if expected_code and result.error.get("code", "") != expected_code:
_failed = true
_message = msg if msg else "Expected error code %s, got %s" % [expected_code, result.error.get("code", "")]
# ----- scene helpers (shared across suites that create/remove Controls) -----
## Add a Control under the scene root. Creates a Panel if ctl is null.
## Returns the scene path, or "" when no scene is open — in which case a
## caller-supplied ctl is freed to prevent leaks.
func _add_control(ctl_name: String, ctl: Control = null) -> String:
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
if ctl != null:
ctl.queue_free()
return ""
if ctl == null:
ctl = Panel.new()
ctl.name = ctl_name
scene_root.add_child(ctl)
ctl.owner = scene_root
return "/" + scene_root.name + "/" + ctl_name
func _remove_control(path: String) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return
var node := McpScenePath.resolve(path, scene_root)
if node != null:
node.get_parent().remove_child(node)
node.queue_free()
@@ -0,0 +1 @@
uid://dlrq2s7jhp71s