Files
2026-07-13 12:49:17 +08:00

1611 lines
66 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Local headless test harness for MCP for Unity.
A single, stdlib-only, cross-platform Python orchestrator that, from one command,
boots a headless Hub-licensed Unity Editor, reuses the resident route-B stdio
bridge, runs up to three test legs (bridge smoke + Unity EditMode + Unity
PlayMode over the Unity Test Framework wire protocol), aggregates a JUnit report,
and tears down. The same entry point is callable from CI so
``.github/workflows/e2e-bridge.yml`` can collapse its boot/wait/discover/run-smoke
shell into one invocation.
Design: ``docs/superpowers/specs/2026-06-07-local-headless-test-harness-design.md``.
The pure (filesystem/Unity-free) helpers sit at the top of this module and are
importable + unit-testable without Unity. The reused server modules
(``transport.legacy.unity_connection`` / ``transport.legacy.port_discovery``)
are imported INSIDE the live functions (after prepending ``Server/src`` to
``sys.path``, mirroring ``Server/tests/e2e/bridge_smoke.py``), so importing this
module never requires ``Server/src`` on ``sys.path``.
Exit-code contract:
0 All blocking legs passed. PlayMode non-blocking failures still 0
(surfaced in the report).
1 A blocking leg failed = real regression (smoke returncode 1; OR EditMode
status=="failed"; OR PlayMode failure under --strict-playmode).
2 Bridge unreachable / setup failure (smoke returncode 2; OR editor
PID/container died during wait with no license/compile signal; OR the
overall watchdog timed out).
3 Project does not compile (read_console compile probe OR compile_fatal
log-grep).
4 No Unity license / Hub seat (license_fatal log-grep after warm-up grace).
5 Editor binary/version not found (discovery layer, before any boot; lists
every searched path).
Run locally (against TestProjects/UnityMCPTests, default isolated tmp status dir)::
python tools/local_harness.py --legs smoke,editmode,playmode \
--project-path TestProjects/UnityMCPTests
# Attach to an already-resident bridge instead of booting one:
python tools/local_harness.py --reuse --legs smoke \
--project-path TestProjects/UnityMCPTests
# Point at an arbitrary consumer project with an explicit editor binary:
python tools/local_harness.py --editor /path/to/Unity \
--project-path ~/TestbedMCP --legs smoke,editmode
# CI parity (DockerLauncher; license threaded as an opaque editor arg):
python tools/local_harness.py --ci --no-warmup \
--legs smoke,editmode,playmode \
--project-path TestProjects/UnityMCPTests --status-dir .unity-mcp \
--editor-arg -manualLicenseFile --editor-arg /root/.../Unity_lic.ulf
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
# ---------------------------------------------------------------------------
# Repo geometry. This file lives at <repo>/tools/local_harness.py, so the repo
# root is parents[1] and Server/src is <repo>/Server/src. We compute these once
# but only mutate sys.path lazily inside the live functions (see _ensure_src_on_path).
# ---------------------------------------------------------------------------
_THIS = Path(__file__).resolve()
REPO_ROOT = _THIS.parents[1]
SERVER_SRC = REPO_ROOT / "Server" / "src"
DEFAULT_PROJECT_PATH = "TestProjects/UnityMCPTests"
BOOT_METHOD = "MCPForUnity.Editor.McpCiBoot.StartStdioForCi"
# Socket-release delay before any resident re-launch, matching StdioBridgeHost's
# own toWait.Wait(2000) for the #688/#692/#1173 Windows TcpListener leak fix.
SOCKET_RELEASE_MS = 2000
# License-fatal grace: tolerate transient Licensing chatter for this long after
# boot (matches claude-nl-suite.yml fatal_after = boot_start + 120 s).
LICENSE_GRACE_S = 120
# PlayMode init-timeout (ms). C# default is 15000; 120000 is only a docstring
# recommendation, so the harness passes it explicitly.
DEFAULT_PLAYMODE_INIT_TIMEOUT_MS = 120000
# ===========================================================================
# Dataclasses / exceptions (Unity-free)
# ===========================================================================
@dataclass
class EditorNotFound(Exception):
"""Raised when no Unity editor binary can be resolved.
Carries the full list of absolute paths probed so main() can print a
remediation hint and exit 5.
"""
searched: list[str] = field(default_factory=list)
def __str__(self) -> str: # pragma: no cover - trivial
return "No Unity editor binary found. Searched:\n " + "\n ".join(self.searched)
@dataclass
class EditorSpec:
"""A resolved editor binary plus the Unity version it satisfies."""
binary: str
version: str
@dataclass
class ReadyInfo:
"""Result of bridge discovery: the port, the instance id, and the status file."""
port: int
instance_id: str
status_file: str
@dataclass
class JUnitCase:
name: str
time_s: float = 0.0
failure: str | None = None
skipped: bool = False
@dataclass
class JUnitSuite:
name: str
cases: list[JUnitCase] = field(default_factory=list)
@dataclass
class LegOutcome:
"""The result of running one leg.
status is one of: "pass", "fail", "skip", "error".
blocking marks whether a failure here can raise the top-level exit code.
exit_code carries a specific severity (1/2/3/4) a failing leg maps to.
"""
name: str
status: str
blocking: bool
detail: str = ""
exit_code: int = 0
junit_suite: JUnitSuite | None = None
# ===========================================================================
# Pure helpers: version resolution
# ===========================================================================
def read_project_version(project_path: str | Path) -> str | None:
"""Parse <P>/ProjectSettings/ProjectVersion.txt for the m_EditorVersion line.
Returns the trimmed token after the colon, or None if the file/line is
absent or malformed (BOM, garbled). Tolerant: never raises.
"""
try:
pv = Path(project_path) / "ProjectSettings" / "ProjectVersion.txt"
text = pv.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
return None
for raw in text.splitlines():
line = raw.lstrip("").strip()
if line.startswith("m_EditorVersion:"):
token = line.split(":", 1)[1].strip()
return token or None
return None
def read_default_version(versions_json: str | Path | None = None) -> str | None:
"""Read defaultVersion from tools/unity-versions.json. Tolerant: returns None
rather than raising on a missing/garbled file."""
path = Path(versions_json) if versions_json else (REPO_ROOT / "tools" / "unity-versions.json")
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
val = data.get("defaultVersion")
return val if isinstance(val, str) and val else None
def resolve_version(project_path: str | Path, versions_json: str | Path | None = None) -> str | None:
"""Version precedence: ProjectVersion.txt, else unity-versions.json defaultVersion."""
return read_project_version(project_path) or read_default_version(versions_json)
def parse_version(version: str) -> tuple[int, int, int, str]:
"""Parse '6000.0.75f1' -> (6000, 0, 75, 'f1').
Tolerant of partial versions; missing numeric parts default to 0 and a
missing suffix to "". Used for comparison and nearest-patch selection.
"""
m = re.match(r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*([A-Za-z]\w*)?", version or "")
if not m:
return (0, 0, 0, "")
major = int(m.group(1))
minor = int(m.group(2)) if m.group(2) else 0
patch = int(m.group(3)) if m.group(3) else 0
suffix = m.group(4) or ""
return (major, minor, patch, suffix)
# ===========================================================================
# Pure helpers: editor binary discovery
# ===========================================================================
def editor_relpath(platform: str | None = None) -> str:
"""Per-OS path of the Unity binary relative to a Hub/<version> directory."""
plat = platform or sys.platform
if plat == "darwin":
return "Unity.app/Contents/MacOS/Unity"
if plat.startswith("win"):
return "Editor/Unity.exe"
return "Editor/Unity" # linux + everything else
def hub_roots(platform: str | None = None, environ: dict[str, str] | None = None) -> list[str]:
"""Per-OS Hub Editor install roots (the directory that holds <version>/ dirs).
Mirrors check-unity-versions.{sh,ps1}. On Windows, both ProgramFiles roots
are iterated.
"""
plat = platform or sys.platform
env = environ if environ is not None else os.environ
home = env.get("HOME") or env.get("USERPROFILE") or str(Path.home())
if plat == "darwin":
return ["/Applications/Unity/Hub/Editor"]
if plat.startswith("win"):
roots: list[str] = []
for var in ("ProgramFiles", "ProgramFiles(x86)"):
base = env.get(var)
if base:
roots.append(str(Path(base) / "Unity" / "Hub" / "Editor"))
if not roots:
roots.append(r"C:\Program Files\Unity\Hub\Editor")
return roots
# linux + everything else
return [str(Path(home) / "Unity" / "Hub" / "Editor")]
def read_secondary_install_path(platform: str | None = None,
environ: dict[str, str] | None = None,
read_text: Callable[[str], str] | None = None) -> str | None:
"""Read Hub's secondaryInstallPath JSON-encoded string.
macOS: ~/Library/Application Support/UnityHub/secondaryInstallPath.json
Windows: %APPDATA%/UnityHub/secondaryInstallPath.json
Linux: ~/.config/UnityHub/secondaryInstallPath.json
Tolerates a missing file, "" or a non-string payload (returns None).
"""
plat = platform or sys.platform
env = environ if environ is not None else os.environ
home = env.get("HOME") or env.get("USERPROFILE") or str(Path.home())
if plat == "darwin":
cfg = Path(home) / "Library" / "Application Support" / "UnityHub" / "secondaryInstallPath.json"
elif plat.startswith("win"):
appdata = env.get("APPDATA") or str(Path(home) / "AppData" / "Roaming")
cfg = Path(appdata) / "UnityHub" / "secondaryInstallPath.json"
else:
xdg = env.get("XDG_CONFIG_HOME") or str(Path(home) / ".config")
cfg = Path(xdg) / "UnityHub" / "secondaryInstallPath.json"
reader = read_text or (lambda p: Path(p).read_text(encoding="utf-8"))
try:
raw = reader(str(cfg))
except OSError:
return None
try:
val = json.loads(raw)
except ValueError:
# The file may store a bare path string without JSON quoting.
val = raw.strip()
if isinstance(val, str) and val.strip():
return val.strip()
return None
def candidate_editor_paths(version: str,
explicit_editor: str | None = None,
platform: str | None = None,
environ: dict[str, str] | None = None,
read_text: Callable[[str], str] | None = None) -> list[str]:
"""Ordered candidate editor binary paths for a resolved version.
Precedence:
1. --editor (explicit_editor)
2. $UNITY_EDITOR
3. Per-OS Hub layout for <version>
4. Hub secondaryInstallPath root, searched for <version>
Nearest-patch fallback (precedence 5) is NOT enumerated here — it is handled
by discover_editor(), which needs to inspect the filesystem.
"""
plat = platform or sys.platform
env = environ if environ is not None else os.environ
relpath = editor_relpath(plat)
out: list[str] = []
if explicit_editor:
out.append(explicit_editor)
env_editor = env.get("UNITY_EDITOR")
if env_editor:
out.append(env_editor)
for root in hub_roots(plat, env):
out.append(str(Path(root) / version / relpath))
sec = read_secondary_install_path(plat, env, read_text)
if sec:
out.append(str(Path(sec) / version / relpath))
return out
def _default_exists(path: str) -> bool:
return os.path.exists(path)
def _default_is_exec(path: str) -> bool:
# On Windows the +x bit is meaningless; existence is enough.
if sys.platform.startswith("win"):
return os.path.isfile(path)
return os.path.isfile(path) and os.access(path, os.X_OK)
def discover_editor(version: str,
explicit_editor: str | None = None,
platform: str | None = None,
environ: dict[str, str] | None = None,
exists: Callable[[str], bool] | None = None,
is_exec: Callable[[str], bool] | None = None,
list_dir: Callable[[str], list[str]] | None = None,
read_text: Callable[[str], str] | None = None) -> EditorSpec:
"""Resolve a Unity editor binary for the requested version.
First existing + executable candidate wins (precedence 1-4 from
candidate_editor_paths). If none match, fall back to the nearest patch
*restricted to the same major.minor*: enumerate installed <version> dirs
under every Hub/secondary root, keep only those whose (major, minor) equals
the target's, pick the max by (patch, suffix). Never cross major.minor.
Filesystem access is injectable (exists / is_exec / list_dir / read_text)
so the whole function is hermetically unit-testable.
Raises EditorNotFound(searched=[...]) carrying every absolute path probed.
"""
plat = platform or sys.platform
env = environ if environ is not None else os.environ
_exists = exists or _default_exists
_is_exec = is_exec or _default_is_exec
_listdir = list_dir or (lambda d: os.listdir(d) if os.path.isdir(d) else [])
relpath = editor_relpath(plat)
searched: list[str] = []
# Precedence 1-4: direct candidates.
for cand in candidate_editor_paths(version, explicit_editor, plat, env, read_text):
searched.append(cand)
if _exists(cand) and _is_exec(cand):
return EditorSpec(binary=cand, version=version)
# Precedence 5: nearest-patch fallback, same major.minor only.
target = parse_version(version)
roots = list(hub_roots(plat, env))
sec = read_secondary_install_path(plat, env, read_text)
if sec:
roots.append(sec)
best: tuple[tuple[int, str], str, str] | None = None # ((patch, suffix), binary, dir_version)
for root in roots:
try:
entries = _listdir(root)
except OSError:
continue
for name in entries:
pv = parse_version(name)
if (pv[0], pv[1]) != (target[0], target[1]):
continue
binary = str(Path(root) / name / relpath)
searched.append(binary)
if not (_exists(binary) and _is_exec(binary)):
continue
key = (pv[2], pv[3])
if best is None or key > best[0]:
best = (key, binary, name)
if best is not None:
return EditorSpec(binary=best[1], version=best[2])
raise EditorNotFound(searched=searched)
# ===========================================================================
# Pure helpers: status-file discovery
# ===========================================================================
def newest_status_file(status_dir: str | Path,
glob_fn: Callable[[str], list[str]] | None = None,
mtime_fn: Callable[[str], float] | None = None) -> str | None:
"""Return the path to the newest unity-mcp-status-*.json under status_dir, or None."""
pattern = str(Path(status_dir) / "unity-mcp-status-*.json")
g = glob_fn or glob.glob
files = list(g(pattern))
if not files:
return None
m = mtime_fn or (lambda p: os.path.getmtime(p))
try:
return max(files, key=m)
except OSError:
# If stat races a deletion, fall back to lexical newest.
return sorted(files)[-1]
def instance_id_from_status(status_file: str | Path, data: dict[str, Any] | None = None) -> str:
"""Derive the <name>@<hash> instance id from a status file path + payload.
hash = the filename segment between 'unity-mcp-status-' and '.json'
(= sha1(Application.dataPath)[:8]). Glob the newest file rather than
recomputing the hash so the harness is robust to /Users vs /private/var
symlink canonicalization. name = project root folder name, derived from the
status payload's project_path (strip trailing /Assets) when available, else
project_name, else "Unknown" — mirroring the C# project_name derivation.
"""
fname = os.path.basename(str(status_file))
h = fname
if h.startswith("unity-mcp-status-"):
h = h[len("unity-mcp-status-"):]
if h.endswith(".json"):
h = h[: -len(".json")]
name = "Unknown"
if data:
project_path = data.get("project_path")
if isinstance(project_path, str) and project_path:
p = project_path.rstrip("/\\")
if p.lower().endswith("assets"):
p = p[:-6].rstrip("/\\")
base = os.path.basename(p)
if base:
name = base
if name == "Unknown":
pn = data.get("project_name")
if isinstance(pn, str) and pn:
name = pn
return f"{name}@{h}"
def port_from_status(data: dict[str, Any] | None) -> int | None:
"""Extract unity_port from a status payload, or None."""
if not isinstance(data, dict):
return None
port = data.get("unity_port")
return port if isinstance(port, int) else None
# ===========================================================================
# Pure helpers: log classification + redaction
# ===========================================================================
_LICENSE_RE = re.compile(
r"No valid Unity"
r"|License is not active"
r"|cannot load ULF"
r"|Signature element not found"
r"|Token not found"
r"|0 entitlement"
r"|Entitlement.*(failed|denied)"
r"|License (activation|return|renewal).*(failed|expired|denied)",
re.IGNORECASE,
)
_COMPILE_RE = re.compile(
r"error CS\d|Scripts have compiler errors|Compilation failed",
re.IGNORECASE,
)
_READY_RE = re.compile(
r"(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)",
re.IGNORECASE,
)
_REDACT_RE = re.compile(r"(?i)((email|serial|license|password|token)\S*)")
_CS_ERROR_RE = re.compile(r"error CS\d")
def redact(text: str) -> str:
"""Redact secret-ish tokens from log echoes.
Mirrors the CI sed idiom
`sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'`.
"""
return _REDACT_RE.sub("[REDACTED]", text or "")
def classify_log(text: str, license_grace_elapsed: bool = True) -> str:
"""Classify an editor log tail.
Returns one of: "license_fatal", "compile_fatal", "ready_ok", "none".
Order: license > compile > ready > none. The license gate is suppressed
until license_grace_elapsed is True (the caller passes whether
boot_start + LICENSE_GRACE_S has passed) to tolerate transient
Licensing::... chatter during warm-up.
"""
if not text:
return "none"
if license_grace_elapsed and _LICENSE_RE.search(text):
return "license_fatal"
if _COMPILE_RE.search(text):
return "compile_fatal"
if _READY_RE.search(text):
return "ready_ok"
return "none"
# ===========================================================================
# Pure helpers: bridge_smoke-shaped response handling (mirrors bridge_smoke.py)
# ===========================================================================
def _ok(resp: Any) -> bool:
"""True when Unity reports success, tolerant of both response shapes."""
if not isinstance(resp, dict):
return False
return resp.get("success") is True or resp.get("status") == "success"
def _result(resp: Any) -> dict:
"""Extract the payload dict from either response shape."""
if isinstance(resp, dict):
inner = resp.get("result")
if isinstance(inner, dict):
return inner
if isinstance(inner, str):
return {"message": inner}
return resp
return {}
def _message(resp: Any) -> str:
"""Best-effort human-readable message from a response."""
if not isinstance(resp, dict):
return repr(resp)
return (
str(resp.get("message"))
if resp.get("message")
else str(resp.get("error"))
if resp.get("error")
else str(_result(resp).get("message"))
if _result(resp).get("message")
else ""
)
def _dig(resp: Any, key: str) -> Any:
"""Return the first value found for ``key`` anywhere in a nested response.
Tolerates the transport wrapping the C# payload under result/data so contract
checks depend on the field, not the exact envelope shape.
"""
stack: list[Any] = [resp]
while stack:
cur = stack.pop()
if isinstance(cur, dict):
if key in cur:
return cur[key]
stack.extend(cur.values())
elif isinstance(cur, list):
stack.extend(cur)
return None
# ===========================================================================
# Pure helpers: JUnit merge + exit aggregation
# ===========================================================================
def merge_junit(suites: list[JUnitSuite]) -> ET.ElementTree:
"""Merge JUnitSuites into a single <testsuites> ElementTree."""
root = ET.Element("testsuites")
total_tests = total_failures = total_skipped = 0
total_time = 0.0
for suite in suites:
if suite is None:
continue
s_failures = sum(1 for c in suite.cases if c.failure is not None)
s_skipped = sum(1 for c in suite.cases if c.skipped)
s_time = sum(c.time_s for c in suite.cases)
total_tests += len(suite.cases)
total_failures += s_failures
total_skipped += s_skipped
total_time += s_time
suite_el = ET.SubElement(
root,
"testsuite",
{
"name": suite.name,
"tests": str(len(suite.cases)),
"failures": str(s_failures),
"skipped": str(s_skipped),
"time": f"{s_time:.3f}",
},
)
for case in suite.cases:
case_el = ET.SubElement(
suite_el,
"testcase",
{"name": case.name, "classname": suite.name, "time": f"{case.time_s:.3f}"},
)
if case.skipped:
ET.SubElement(case_el, "skipped")
if case.failure is not None:
fail_el = ET.SubElement(case_el, "failure", {"message": case.failure[:200]})
fail_el.text = case.failure
root.set("tests", str(total_tests))
root.set("failures", str(total_failures))
root.set("skipped", str(total_skipped))
root.set("time", f"{total_time:.3f}")
return ET.ElementTree(root)
def aggregate_exit(outcomes: list[LegOutcome]) -> int:
"""Top-level exit code = max-severity by precedence 5 > 4 > 3 > 2 > 1 > 0.
Setup/infra codes dominate test-result codes so CI can tell environment
problems from regressions. A non-blocking leg (PlayMode without
--strict-playmode) never raises the code above 0 on its own.
"""
code = 0
precedence = {5: 5, 4: 4, 3: 3, 2: 2, 1: 1, 0: 0}
best_rank = -1
for o in outcomes:
if o.status not in ("fail", "error"):
continue
if not o.blocking:
continue
c = o.exit_code if o.exit_code else 1
rank = precedence.get(c, 0)
if rank > best_rank:
best_rank = rank
code = c
return code
# ===========================================================================
# Argument parser (pure, importable, Unity-free)
# ===========================================================================
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="local_harness.py",
description="Local headless test harness: boot Unity, run smoke/EditMode/PlayMode legs, aggregate, teardown.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument(
"--legs",
default="smoke,editmode,playmode",
help="Comma list of legs to run (subset of smoke,editmode,playmode).",
)
p.add_argument(
"--project-path",
default=DEFAULT_PROJECT_PATH,
help="Unity project to boot (repo-relative or absolute). Default: TestProjects/UnityMCPTests.",
)
p.add_argument("--editor", default=None, help="Explicit Unity binary (discovery precedence 1).")
p.add_argument("--unity-version", default=None, help="Override the resolved Unity version.")
p.add_argument("--ci", action="store_true", help="Use DockerLauncher + repo .unity-mcp status dir; implies --no-warmup semantics.")
p.add_argument("--status-dir", default=None, help="Status-file directory. Default: fresh tmp dir (local) / <workspace>/.unity-mcp (--ci).")
p.add_argument("--reuse", action="store_true", help="Attach to an already-resident bridge via ~/.unity-mcp; owns_editor=False.")
p.add_argument("--keep-alive", action="store_true", help="Leave the editor running after legs (no teardown of an owned editor).")
p.add_argument("--no-warmup", action="store_true", help="Skip the warm-up phase.")
p.add_argument("--strict-playmode", action="store_true", help="Promote a PlayMode failure to a blocking failure (exit 1).")
p.add_argument("--native-editmode", action="store_true", help="Optional CI-parity native -runTests EditMode leg, serialized after resident teardown.")
p.add_argument(
"--junit",
default="reports/junit-e2e-bridge.xml",
help="Smoke JUnit path. EditMode/PlayMode write reports/junit-editmode.xml / reports/junit-playmode.xml alongside.",
)
p.add_argument("--reports", default=None, help="Reports directory. Default: dirname(--junit) or reports/.")
p.add_argument("--boot-timeout", type=int, default=900, help="Warm-up + resident-boot budget (s).")
p.add_argument("--bridge-wait", type=int, default=600, help="Bridge-ready budget (s).")
p.add_argument(
"--playmode-init-timeout",
type=int,
default=DEFAULT_PLAYMODE_INIT_TIMEOUT_MS,
help="initTimeout (ms) passed verbatim to PlayMode run_tests.",
)
p.add_argument("--overall-timeout", type=int, default=2400, help="Wall-clock watchdog (s); on expiry kill owned PID, exit 2.")
p.add_argument("--max-retries", type=int, default=8, help="Reload retries per wire command.")
p.add_argument("--retry-ms", type=int, default=250, help="Reload retry delay (ms).")
p.add_argument("--editor-arg", action="append", default=[], dest="editor_args", help="Opaque extra editor arg appended to the resident argv (repeatable).")
return p
# Task-prose aliases for the canonical pure helpers (design spec §13 uses
# discover_editor / resolve_version / classify_log / aggregate_exit; the harness
# task wording uses these descriptive names). Both are importable + Unity-free.
resolve_editor_binary = discover_editor
resolve_unity_version = resolve_version
classify_editor_log = classify_log
aggregate_exit_code = aggregate_exit
ALLOWED_LEGS = ("smoke", "editmode", "playmode")
def parse_legs(legs: str) -> list[str]:
"""Normalize the --legs CSV into an ordered, de-duplicated list.
Lenient by design -- unknown values are dropped (see test_drops_unknown_legs).
The CLI entry point (main) validates raw input against ALLOWED_LEGS and
rejects unknown values before calling this; keep this a pure normalizer.
"""
seen: set[str] = set()
out: list[str] = []
for part in (legs or "").split(","):
leg = part.strip().lower()
if leg and leg in ALLOWED_LEGS and leg not in seen:
seen.add(leg)
out.append(leg)
return out
# ===========================================================================
# Launcher seam (local vs docker). Inlined here so the harness is a single,
# self-contained, runnable file. Arg-builders are pure string assemblers.
# ===========================================================================
@dataclass
class Handle:
"""A live editor handle. Exactly one of (proc, container) is meaningful."""
proc: Any = None # subprocess.Popen for LocalLauncher
container: str | None = None # container name for DockerLauncher
log_path: str | None = None
pid: int | None = None
class LocalLauncher:
"""Boots a native Hub editor via detached Popen; PID-based liveness."""
def __init__(self, args: argparse.Namespace):
self.args = args
def resolve_editor(self, project_path: Path) -> EditorSpec:
version = self.args.unity_version or resolve_version(project_path)
if not version:
raise EditorNotFound(searched=[])
return discover_editor(version, explicit_editor=self.args.editor)
@staticmethod
def warmup_argv(editor: str, project_path: Path, log_path: Path) -> list[str]:
return [
editor, "-batchmode", "-nographics", "-quit",
"-projectPath", str(project_path),
"-logFile", str(log_path),
]
@staticmethod
def resident_argv(editor: str, project_path: Path, log_path: Path,
extra_editor_args: list[str]) -> list[str]:
return [
editor, "-batchmode", "-nographics",
"-projectPath", str(project_path),
"-logFile", str(log_path),
*list(extra_editor_args or []),
"-executeMethod", BOOT_METHOD,
]
@staticmethod
def resident_env(base_env: dict[str, str], status_dir: Path) -> dict[str, str]:
env = dict(base_env)
env["UNITY_MCP_ALLOW_BATCH"] = "1"
env["UNITY_MCP_STATUS_DIR"] = str(status_dir)
return env
def warmup(self, editor: str, project_path: Path, log_path: Path, timeout_s: int) -> int:
argv = self.warmup_argv(editor, project_path, log_path)
proc = subprocess.Popen(argv)
try:
return proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return -1
def launch(self, editor: str, project_path: Path, status_dir: Path, log_path: Path,
extra_editor_args: list[str]) -> Handle:
argv = self.resident_argv(editor, project_path, log_path, extra_editor_args)
env = self.resident_env(os.environ, status_dir)
kwargs: dict[str, Any] = {"env": env}
if hasattr(os, "setsid"):
kwargs["start_new_session"] = True
elif sys.platform.startswith("win"):
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
proc = subprocess.Popen(argv, **kwargs)
return Handle(proc=proc, log_path=str(log_path), pid=proc.pid)
def is_alive(self, handle: Handle) -> bool:
if handle.proc is not None:
return handle.proc.poll() is None
if handle.pid is None:
return False
try:
os.kill(handle.pid, 0)
return True
except OSError:
return False
def tail_log(self, handle: Handle, n: int) -> str:
if not handle.log_path:
return ""
try:
with open(handle.log_path, "r", encoding="utf-8", errors="replace") as f:
return "".join(f.readlines()[-n:])
except OSError:
return ""
def fixup_permissions(self, status_dir: Path) -> None:
return # local: no-op
def teardown(self, handle: Handle, grace_s: float = 10.0) -> None:
proc = handle.proc
pid = handle.pid
try:
if proc is not None:
proc.terminate()
try:
proc.wait(timeout=grace_s)
return
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return
if pid is not None:
os.kill(pid, signal.SIGTERM)
deadline = time.time() + grace_s
while time.time() < deadline:
try:
os.kill(pid, 0)
except OSError:
return
time.sleep(0.2)
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
except OSError:
pass
class DockerLauncher:
"""Reproduces e2e-bridge.yml's docker run exactly; docker-based liveness."""
CONTAINER = "unity-mcp"
def __init__(self, args: argparse.Namespace):
self.args = args
def resolve_editor(self, project_path: Path) -> EditorSpec:
# CI short-circuits to the fixed image entrypoint; never touches Hub.
return EditorSpec(binary="/opt/unity/Editor/Unity", version=self.args.unity_version or "docker")
@staticmethod
def docker_run_argv(image: str, workspace: Path, project_path: Path, status_dir: Path,
log_path: str, extra_editor_args: list[str],
container: str = "unity-mcp",
runner_temp: str | None = None) -> list[str]:
# When RUNNER_TEMP is present (GitHub Actions), mount the same license /
# config / cache volumes the warm-up + activation steps populated so the
# resident bridge container sees the staged ULF/EBL seat. Locally these
# mounts are simply omitted.
license_mounts: list[str] = []
if runner_temp:
rt = str(runner_temp)
license_mounts = [
"-v", f"{rt}/unity-config:/root/.config/unity3d",
"-v", f"{rt}/unity-local:/root/.local/share/unity3d",
"-v", f"{rt}/unity-cache:/root/.cache/unity3d",
]
return [
"docker", "run", "-d", "--name", container, "--network", "host",
"-e", "HOME=/root",
"-e", "UNITY_MCP_ALLOW_BATCH=1",
"-e", f"UNITY_MCP_STATUS_DIR={status_dir}",
"-e", "UNITY_MCP_BIND_HOST=127.0.0.1",
"-v", f"{workspace}:{workspace}", "-w", str(workspace),
*license_mounts,
image,
"/opt/unity/Editor/Unity", "-batchmode", "-nographics",
"-logFile", log_path,
"-projectPath", str(project_path),
*list(extra_editor_args or []),
"-executeMethod", BOOT_METHOD,
]
def warmup(self, editor: str, project_path: Path, log_path: Path, timeout_s: int) -> int:
return 0 # no-op in CI (YAML already warmed up)
def launch(self, editor: str, project_path: Path, status_dir: Path, log_path: Path,
extra_editor_args: list[str]) -> Handle:
image = os.environ.get("UNITY_IMAGE", "")
workspace = Path(os.environ.get("GITHUB_WORKSPACE", str(REPO_ROOT)))
runner_temp = os.environ.get("RUNNER_TEMP")
container_log = "/root/.config/unity3d/Editor.log"
subprocess.run(["docker", "rm", "-f", self.CONTAINER],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
argv = self.docker_run_argv(image, workspace, project_path, status_dir,
container_log, extra_editor_args, self.CONTAINER,
runner_temp)
subprocess.run(argv, check=True)
return Handle(container=self.CONTAINER, log_path=container_log)
def is_alive(self, handle: Handle) -> bool:
try:
out = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", self.CONTAINER],
capture_output=True, text=True, check=False,
)
return out.stdout.strip() == "running"
except OSError:
return False
def tail_log(self, handle: Handle, n: int) -> str:
try:
out = subprocess.run(
["docker", "logs", "--tail", str(n), self.CONTAINER],
capture_output=True, text=True, check=False,
)
return (out.stdout or "") + (out.stderr or "")
except OSError:
return ""
def fixup_permissions(self, status_dir: Path) -> None:
subprocess.run(["docker", "exec", self.CONTAINER, "chmod", "-R", "a+rwx", str(status_dir)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
def teardown(self, handle: Handle, grace_s: float = 10.0) -> None:
subprocess.run(["docker", "rm", "-f", self.CONTAINER],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
def make_launcher(args: argparse.Namespace):
return DockerLauncher(args) if args.ci else LocalLauncher(args)
# ===========================================================================
# Live functions (Unity-touching; guarded under main()).
# ===========================================================================
def _ensure_src_on_path() -> None:
"""Prepend <repo>/Server/src to sys.path (mirrors bridge_smoke.py)."""
src = str(SERVER_SRC)
if SERVER_SRC.is_dir() and src not in sys.path:
sys.path.insert(0, src)
def _read_status(status_file: str) -> dict[str, Any] | None:
try:
with open(status_file, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def _tcp_probe(port: int, timeout: float = 0.5) -> bool:
try:
with socket.create_connection(("127.0.0.1", int(port)), timeout):
return True
except OSError:
return False
def wait_for_ready(launcher, handle: Handle, status_dir: Path, bridge_wait_s: int,
boot_start: float, deadline: float) -> ReadyInfo:
"""Poll status-file discovery + TCP probe up to bridge_wait_s.
Liveness (launcher.is_alive) is the authoritative death signal: a slow-but-
healthy cold boot may show zero discoverable instances for a while, which is
tolerated. On editor death, classify the log tail -> raise SystemExit with
the mapped exit code (4 license / 3 compile / else 2). On overall watchdog
expiry -> SystemExit(2).
"""
wait_deadline = time.time() + bridge_wait_s
while True:
now = time.time()
if now > deadline:
_redacted_tail(launcher, handle)
raise SystemExit(2)
if now > wait_deadline:
print("::error:: bridge not ready before --bridge-wait deadline")
_redacted_tail(launcher, handle)
raise SystemExit(2)
if not launcher.is_alive(handle):
grace_elapsed = (now - boot_start) >= LICENSE_GRACE_S
tail = launcher.tail_log(handle, 200)
kind = classify_log(tail, license_grace_elapsed=grace_elapsed)
print("::error:: editor process died during bridge wait")
print(redact(tail))
if kind == "license_fatal":
raise SystemExit(4)
if kind == "compile_fatal":
raise SystemExit(3)
raise SystemExit(2)
status_file = newest_status_file(status_dir)
if status_file:
data = _read_status(status_file)
port = port_from_status(data)
if port and _tcp_probe(port):
instance_id = instance_id_from_status(status_file, data)
launcher.fixup_permissions(status_dir)
return ReadyInfo(port=port, instance_id=instance_id, status_file=status_file)
time.sleep(2)
def _redacted_tail(launcher, handle: Handle, n: int = 200) -> None:
tail = launcher.tail_log(handle, n)
if tail:
print(redact(tail))
def _console_entries(resp: Any) -> list[Any]:
"""Pull read_console log entries out of either envelope shape.
Non-paging read_console returns the entries as a BARE LIST under "data"
(ReadConsole.HandleCommand -> SuccessResponse(message, entries)); paging
returns them under data.items. Either may sit under a top-level "result".
"""
body = _dig(resp, "data")
if isinstance(body, list):
return body
if isinstance(body, dict) and isinstance(body.get("items"), list):
return body["items"]
dug = _dig(resp, "items")
if isinstance(dug, list):
return dug
raw = resp.get("result") if isinstance(resp, dict) else None
if isinstance(raw, list):
return raw
return []
def compile_probe(instance_id: str, max_retries: int, retry_ms: int, send=None) -> bool:
"""Run a read-only read_console probe before any UTF leg.
Returns True if the project compiles (no `error CS\\d`), False if a compile
error is detected. Driving raw run_tests bypasses the Python preflight()
that would otherwise hang on a non-compiling project, so this probe guards
the UTF legs explicitly. ``send`` is an injection seam for tests; in
production it defaults to the real bridge wire.
"""
if send is None:
_ensure_src_on_path()
from transport.legacy.unity_connection import send_command_with_retry as send
try:
resp = send(
"read_console",
{"action": "get", "types": ["error"], "count": "200", "include_stacktrace": False},
instance_id=instance_id,
max_retries=max_retries,
retry_ms=retry_ms,
retry_on_reload=True,
)
except Exception:
# A timed-out / errored probe is inconclusive; do not block the UTF legs.
return True
if not _ok(resp):
# An errored probe is inconclusive; do not block on it.
return True
entries = _console_entries(resp)
for e in entries:
if isinstance(e, dict):
msg = str(e.get("message") or "")
elif isinstance(e, str):
msg = e
else:
msg = ""
if _CS_ERROR_RE.search(msg):
return False
return True
def run_smoke_leg(instance_id: str, junit_path: Path, max_retries: int, retry_ms: int,
deadline: float | None = None, python_exe: str | None = None) -> LegOutcome:
"""Run bridge_smoke.py as a subprocess; honor its 0/1/2 exit contract.
Bounded by the overall deadline so a wedged smoke run cannot outlive the
--overall-timeout budget (the watchdog is the hard backstop; passing a
timeout here reaps the child cleanly first). retry_ms is threaded through so
all three legs share the configured reload-retry delay.
"""
smoke = REPO_ROOT / "Server" / "tests" / "e2e" / "bridge_smoke.py"
py = python_exe or sys.executable
argv = [py, str(smoke), "--instance", instance_id, "--junit", str(junit_path),
"--max-retries", str(max_retries), "--retry-ms", str(retry_ms)]
timeout = max(1.0, deadline - time.time()) if deadline is not None else None
try:
rc = subprocess.run(argv, check=False, timeout=timeout).returncode
except subprocess.TimeoutExpired:
return LegOutcome("smoke", "error", blocking=True,
detail="bridge smoke timed out (overall budget)", exit_code=2)
if rc == 0:
return LegOutcome("smoke", "pass", blocking=True, detail="bridge smoke passed", exit_code=0)
if rc == 1:
return LegOutcome("smoke", "fail", blocking=True, detail="bridge smoke assertion regression", exit_code=1)
# rc == 2 (or anything else) -> bridge unreachable / setup failure.
return LegOutcome("smoke", "error", blocking=True, detail="no bridge reachable", exit_code=2)
def _start_utf(send, mode: str, instance_id: str, init_timeout_ms: int | None,
max_retries: int, retry_ms: int) -> tuple[str | None, dict[str, Any] | Any]:
"""Issue run_tests; return (job_id, raw_start_response). Gates on result.success."""
params: dict[str, Any] = {"mode": mode, "includeFailedTests": True}
if init_timeout_ms is not None:
params["initTimeout"] = init_timeout_ms
# tests_running back-off: a "tests already running" reply is an ErrorResponse
# (success:false), so it must be detected BEFORE the _ok() gate, not after.
for _ in range(5):
try:
start = send("run_tests", params, instance_id=instance_id,
max_retries=max_retries, retry_ms=retry_ms, retry_on_reload=True)
except Exception:
# Transport hiccup starting the job (editor briefly busy / reloading);
# back off and retry rather than crashing.
time.sleep(min(float(retry_ms) / 1000.0 * 2, 2.0))
continue
err = None
if isinstance(start, dict):
err = start.get("error") or start.get("code") or _dig(start, "error")
if err == "tests_running":
back = _dig(start, "retry_after_ms") or 1000
time.sleep(min(float(back) / 1000.0, 5.0))
continue
if not _ok(start):
return None, start
job_id = _dig(start, "job_id")
if isinstance(job_id, (str, int)):
return str(job_id), start
return None, start
return None, {"error": "tests_running (exhausted)"}
def _poll_utf(send, job_id: str, instance_id: str, deadline: float,
max_retries: int, retry_ms: int) -> dict[str, Any] | Any:
"""Poll get_test_job until terminal {succeeded, failed} or deadline.
A returned MCPResponse / reason=="reloading" / hint=="retry" is treated as
non-terminal (keep polling). Returns the last response (terminal or a wedge
marker dict when the deadline expires).
"""
while time.time() < deadline:
try:
poll = send("get_test_job", {"job_id": job_id, "includeFailedTests": True},
instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms,
retry_on_reload=True)
except Exception:
# Unity blocks its main thread while running tests, so get_test_job can
# time out mid-run. That is NOT terminal -- keep polling until the editor
# frees up or the deadline expires (rather than crashing the harness).
time.sleep(2)
continue
if not isinstance(poll, dict):
time.sleep(2)
continue
reason = _dig(poll, "reason")
hint = _dig(poll, "hint")
if reason == "reloading" or hint == "retry":
time.sleep(2)
continue
status = _dig(poll, "status")
if status in ("succeeded", "failed"):
return poll
time.sleep(2)
return {"_wedge": True}
def _outcome_from_terminal(name: str, mode: str, terminal: dict[str, Any] | Any,
blocking: bool) -> LegOutcome:
"""Map a terminal get_test_job response into a LegOutcome + JUnit suite."""
status = _dig(terminal, "status")
suite = JUnitSuite(name=name)
if isinstance(terminal, dict) and terminal.get("_wedge"):
suite.cases.append(JUnitCase(name=f"{mode}.wedge", failure="no terminal status within budget"))
return LegOutcome(name, "fail", blocking=blocking, detail="wedge (no terminal status)",
exit_code=1, junit_suite=suite)
if status == "succeeded":
result = _dig(terminal, "result") or {}
summary = (result.get("summary") if isinstance(result, dict) else None) or {}
total = int(summary.get("total", 0) or 0)
passed = int(summary.get("passed", 0) or 0)
failed = int(summary.get("failed", 0) or 0)
skipped = int(summary.get("skipped", 0) or 0)
duration = float(summary.get("durationSeconds", 0.0) or 0.0)
rows = result.get("results") if isinstance(result, dict) else None
if isinstance(rows, list) and rows:
for r in rows:
if not isinstance(r, dict):
continue
rname = str(r.get("fullName") or r.get("name") or f"{mode}.test")
rtime = float(r.get("durationSeconds", 0.0) or 0.0)
state = str(r.get("state") or "")
if state.lower() in ("failed", "error"):
fmsg = str(r.get("message") or "") + "\n" + str(r.get("stackTrace") or "")
suite.cases.append(JUnitCase(name=rname, time_s=rtime, failure=fmsg.strip()))
elif state.lower() in ("skipped", "ignored", "inconclusive"):
suite.cases.append(JUnitCase(name=rname, time_s=rtime, skipped=True))
else:
suite.cases.append(JUnitCase(name=rname, time_s=rtime))
else:
# No per-test rows: synthesize from the summary.
for i in range(passed):
suite.cases.append(JUnitCase(name=f"{mode}.passed.{i}"))
for i in range(failed):
suite.cases.append(JUnitCase(name=f"{mode}.failed.{i}", failure="failed (no detail)"))
for i in range(skipped):
suite.cases.append(JUnitCase(name=f"{mode}.skipped.{i}", skipped=True))
if not suite.cases and total == 0:
suite.cases.append(JUnitCase(name=f"{mode}.empty", time_s=duration))
if failed > 0:
return LegOutcome(name, "fail", blocking=blocking,
detail=f"{failed}/{total} {mode} tests failed", exit_code=1,
junit_suite=suite)
return LegOutcome(name, "pass", blocking=blocking,
detail=f"{passed}/{total} {mode} tests passed", exit_code=0,
junit_suite=suite)
# status == "failed": data.result is null; surface error + capped failures.
error = _dig(terminal, "error") or "test job failed"
failures = _dig(terminal, "failures_so_far") or []
detail = str(error)
fail_text = detail
if isinstance(failures, list) and failures:
for fr in failures:
if isinstance(fr, dict):
fail_text += "\n - " + str(fr.get("full_name") or "") + ": " + str(fr.get("message") or "")
suite.cases.append(JUnitCase(name=f"{mode}.job", failure=fail_text))
return LegOutcome(name, "fail", blocking=blocking, detail=detail, exit_code=1, junit_suite=suite)
def run_utf_leg(mode: str, instance_id: str, blocking: bool, deadline: float,
max_retries: int, retry_ms: int, init_timeout_ms: int | None = None) -> LegOutcome:
"""Drive one EditMode/PlayMode leg over the raw run_tests/get_test_job wire."""
_ensure_src_on_path()
from transport.legacy.unity_connection import send_command_with_retry as send
name = "editmode" if mode == "EditMode" else "playmode"
job_id, start = _start_utf(send, mode, instance_id, init_timeout_ms, max_retries, retry_ms)
if job_id is None:
suite = JUnitSuite(name=name, cases=[JUnitCase(name=f"{name}.start", failure=_message(start) or "run_tests start failed")])
return LegOutcome(name, "fail", blocking=blocking, detail="run_tests start failed", exit_code=1, junit_suite=suite)
terminal = _poll_utf(send, job_id, instance_id, deadline, max_retries, retry_ms)
return _outcome_from_terminal(name, mode, terminal, blocking)
def _ensure_clean_editmode(send, instance_id: str, max_retries: int, retry_ms: int,
deadline: float) -> None:
"""Best-effort: wait until no PlayMode/test job is running (S0)."""
end = min(time.time() + 30, deadline)
while time.time() < end:
try:
resp = send("get_test_job", {"job_id": ""}, instance_id=instance_id,
max_retries=max_retries, retry_ms=retry_ms, retry_on_reload=True)
except Exception:
# Editor busy/unresponsive; best-effort wait, treat as still settling.
time.sleep(2)
continue
status = _dig(resp, "status")
if status not in ("running",):
return
time.sleep(2)
def run_playmode_with_retry(instance_id: str, deadline: float, max_retries: int, retry_ms: int,
init_timeout_ms: int, strict: bool,
relaunch: Callable[[], str] | None = None) -> LegOutcome:
"""PlayMode state machine: start, poll, classify-can-rerun, retry ONCE.
Non-blocking by default; --strict-playmode promotes failure to blocking.
On a "can rerun" failure (error contains 'failed to initialize') or a wedge,
re-establish clean EditMode and repeat once. A wedge may relaunch the editor
(honoring the socket-release delay) before the single retry.
"""
_ensure_src_on_path()
from transport.legacy.unity_connection import send_command_with_retry as send
blocking = bool(strict)
def attempt(inst: str) -> LegOutcome:
_ensure_clean_editmode(send, inst, max_retries, retry_ms, deadline)
job_id, start = _start_utf(send, "PlayMode", inst, init_timeout_ms, max_retries, retry_ms)
if job_id is None:
suite = JUnitSuite(name="playmode", cases=[JUnitCase(name="playmode.start", failure=_message(start) or "run_tests start failed")])
return LegOutcome("playmode", "fail", blocking=blocking, detail="run_tests start failed", exit_code=1, junit_suite=suite)
terminal = _poll_utf(send, job_id, inst, deadline, max_retries, retry_ms)
return _outcome_from_terminal("playmode", "PlayMode", terminal, blocking)
first = attempt(instance_id)
if first.status == "pass":
return first
error_text = (first.detail or "").lower()
can_rerun = ("failed to initialize" in error_text) or ("wedge" in error_text)
if not can_rerun:
return first
# A wedge may need the editor relaunched (respecting the socket-release delay).
inst = instance_id
if "wedge" in error_text and relaunch is not None:
time.sleep(SOCKET_RELEASE_MS / 1000.0)
inst = relaunch()
second = attempt(inst)
second.detail = f"retry-once: {second.detail}"
return second
def write_reports(junit_path: Path, reports_dir: Path, outcomes: list[LegOutcome]) -> None:
"""Write merged + per-leg JUnit files: smoke -> --junit, others alongside."""
reports_dir.mkdir(parents=True, exist_ok=True)
per_leg_file = {
"smoke": junit_path,
"editmode": reports_dir / "junit-editmode.xml",
"playmode": reports_dir / "junit-playmode.xml",
}
for o in outcomes:
if o.junit_suite is None:
continue
target = per_leg_file.get(o.name, reports_dir / f"junit-{o.name}.xml")
merge_junit([o.junit_suite]).write(str(target), encoding="utf-8", xml_declaration=True)
# Also write a combined report.
suites = [o.junit_suite for o in outcomes if o.junit_suite is not None]
if suites:
merge_junit(suites).write(str(reports_dir / "junit-all.xml"), encoding="utf-8", xml_declaration=True)
def _print_summary(outcomes: list[LegOutcome], exit_code: int) -> None:
print("== local harness summary ==")
for o in outcomes:
tag = {"pass": "PASS", "fail": "FAIL", "skip": "SKIP", "error": "ERROR"}.get(o.status, o.status.upper())
block = "blocking" if o.blocking else "non-blocking"
print(f" [{tag}] {o.name} ({block}) -- {o.detail}")
print(f"== exit {exit_code} ==")
def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
requested_legs = [p.strip().lower() for p in (args.legs or "").split(",") if p.strip()]
invalid_legs = [leg for leg in requested_legs if leg not in ALLOWED_LEGS]
if invalid_legs:
print(f"::error:: --legs included invalid value(s): {', '.join(invalid_legs)} "
f"(allowed: {', '.join(ALLOWED_LEGS)})")
return 2
legs = parse_legs(args.legs)
if not legs:
print(f"::error:: --legs did not include any valid values (allowed: {', '.join(ALLOWED_LEGS)})")
return 2
if args.ci:
args.no_warmup = True
if not os.environ.get("UNITY_IMAGE"):
print("::error:: --ci requires the UNITY_IMAGE environment variable "
"(the unityci/editor image to run the headless Editor in)")
return 2
# Resolve project path (repo-relative or absolute).
project_path = Path(args.project_path)
if not project_path.is_absolute():
project_path = (REPO_ROOT / project_path).resolve()
# Reports / JUnit geometry.
junit_path = Path(args.junit)
if not junit_path.is_absolute():
junit_path = (REPO_ROOT / junit_path).resolve()
reports_dir = Path(args.reports).resolve() if args.reports else junit_path.parent
# Status-dir + isolation. Default local: fresh tmp dir; --ci / --status-dir override.
owns_status_dir = False
if args.status_dir:
sd = Path(args.status_dir)
status_dir = sd if sd.is_absolute() else (REPO_ROOT / sd).resolve()
status_dir.mkdir(parents=True, exist_ok=True)
elif args.ci:
status_dir = (REPO_ROOT / ".unity-mcp").resolve()
status_dir.mkdir(parents=True, exist_ok=True)
elif args.reuse:
status_dir = Path.home() / ".unity-mcp"
else:
status_dir = Path(tempfile.mkdtemp(prefix="unity-mcp-harness-"))
owns_status_dir = True
launcher = make_launcher(args)
boot_start = time.time()
deadline = boot_start + args.overall_timeout
handle: Handle | None = None
owns_editor = not (args.reuse or args.keep_alive)
outcomes: list[LegOutcome] = []
def do_teardown() -> None:
# Only kill the editor we started; clean only our own status files.
if handle is not None and owns_editor and not args.keep_alive:
try:
launcher.teardown(handle)
except Exception:
pass
if owns_status_dir:
try:
for p in glob.glob(str(status_dir / "unity-mcp-status-*.json")):
try:
os.remove(p)
except OSError:
pass
shutil.rmtree(status_dir, ignore_errors=True)
except OSError:
pass
def _signal_handler(signum, frame): # pragma: no cover - signal path
do_teardown()
raise SystemExit(2)
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, _signal_handler)
except (ValueError, OSError):
pass
# Hard wall-clock watchdog: the polled deadlines inside the wait/poll loops do
# not cover blocking subprocess/socket calls (smoke subprocess, compile probe),
# so a background daemon enforces --overall-timeout across ALL phases.
_watchdog_stop = threading.Event()
def _watchdog() -> None: # pragma: no cover - timing/daemon path
remaining = deadline - time.time()
if remaining > 0:
_watchdog_stop.wait(remaining)
if _watchdog_stop.is_set():
return
print(f"::error:: overall watchdog timed out after {args.overall_timeout}s -- killing editor", flush=True)
try:
do_teardown()
finally:
os._exit(2)
threading.Thread(target=_watchdog, name="harness-watchdog", daemon=True).start()
try:
# --- Reuse path: attach to a resident bridge via ~/.unity-mcp ---
if args.reuse:
# Read the user's default registry without our own STATUS_DIR override.
prev = os.environ.pop("UNITY_MCP_STATUS_DIR", None)
try:
status_file = _find_reuse_status(status_dir, project_path)
finally:
if prev is not None:
os.environ["UNITY_MCP_STATUS_DIR"] = prev
if not status_file:
print(f"::error:: --reuse: no resident bridge found for {project_path} under {status_dir}")
return 2
data = _read_status(status_file)
port = port_from_status(data)
if not port or not _tcp_probe(port):
print(f"::error:: --reuse: resident bridge for {project_path} is not reachable")
return 2
os.environ["UNITY_MCP_STATUS_DIR"] = str(status_dir)
instance_id = instance_id_from_status(status_file, data)
ready = ReadyInfo(port=port, instance_id=instance_id, status_file=status_file)
else:
# --- Boot path ---
try:
spec = launcher.resolve_editor(project_path)
except EditorNotFound as e:
searched = e.searched if e.searched else ["<no candidate paths>"]
print("::error:: no matching Unity editor found")
for s in searched:
print(f" searched: {s}")
want = args.unity_version or resolve_version(project_path) or "<unknown>"
maj_min = ".".join(str(x) for x in parse_version(want)[:2])
print(f" install a matching {maj_min}.x editor or pass --editor (wanted {want})")
return 5
os.environ["UNITY_MCP_STATUS_DIR"] = str(status_dir)
# Phase 1 -- warm-up (skip with --no-warmup / --ci).
if not args.no_warmup:
warmup_log = status_dir / "warmup.log"
rc = launcher.warmup(spec.binary, project_path, warmup_log, args.boot_timeout)
if rc not in (0,):
tail = launcher.tail_log(Handle(log_path=str(warmup_log)), 200)
kind = classify_log(tail, license_grace_elapsed=(time.time() - boot_start) >= LICENSE_GRACE_S)
if kind == "license_fatal":
print(redact(tail))
return 4
if kind == "compile_fatal":
print(redact(tail))
return 3
# Non-zero warm-up without a clear signal: continue to resident boot.
# Phase 2 -- resident (NO -quit).
editor_log = status_dir / "editor.log"
handle = launcher.launch(spec.binary, project_path, status_dir, editor_log, args.editor_args)
ready = wait_for_ready(launcher, handle, status_dir, args.bridge_wait, boot_start, deadline)
instance_id = ready.instance_id
# Pin the instance so smoke + UTF target our own editor.
os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = instance_id
print(f"== bridge ready: instance={instance_id} port={ready.port} ==")
# --- Compile probe before any UTF leg (exit 3 on compile failure) ---
wants_utf = ("editmode" in legs) or ("playmode" in legs)
compile_ok = True
if wants_utf:
compile_ok = compile_probe(instance_id, args.max_retries, args.retry_ms)
if not compile_ok:
print("::error:: project does not compile -- skipping UTF legs")
# --- Smoke leg ---
if "smoke" in legs:
outcomes.append(run_smoke_leg(instance_id, junit_path, args.max_retries,
args.retry_ms, deadline=deadline))
# --- EditMode leg ---
if "editmode" in legs:
if not compile_ok:
outcomes.append(LegOutcome("editmode", "fail", blocking=True,
detail="project does not compile", exit_code=3))
else:
outcomes.append(run_utf_leg("EditMode", instance_id, blocking=True,
deadline=deadline, max_retries=args.max_retries,
retry_ms=args.retry_ms))
# --- PlayMode leg (default-ON, NON-BLOCKING unless --strict-playmode) ---
if "playmode" in legs:
if not compile_ok:
outcomes.append(LegOutcome("playmode", "fail", blocking=bool(args.strict_playmode),
detail="project does not compile", exit_code=3))
else:
def _relaunch() -> str:
nonlocal handle, ready, instance_id
if handle is not None and owns_editor:
launcher.teardown(handle)
time.sleep(SOCKET_RELEASE_MS / 1000.0)
editor_log = status_dir / "editor.log"
handle = launcher.launch(spec.binary, project_path, status_dir, editor_log, args.editor_args)
ready = wait_for_ready(launcher, handle, status_dir, args.bridge_wait, time.time(), deadline)
instance_id = ready.instance_id
os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = instance_id
return instance_id
relaunch = _relaunch if (owns_editor and not args.reuse) else None
outcomes.append(run_playmode_with_retry(
instance_id, deadline, args.max_retries, args.retry_ms,
args.playmode_init_timeout, bool(args.strict_playmode), relaunch=relaunch))
# Aggregate + write reports.
write_reports(junit_path, reports_dir, outcomes)
exit_code = aggregate_exit(outcomes)
# If a compile failure surfaced, it dominates (precedence keeps 3).
_print_summary(outcomes, exit_code)
return exit_code
except SystemExit as e:
return int(e.code) if isinstance(e.code, int) else 2
finally:
_watchdog_stop.set()
do_teardown()
def _norm_project_root(p: str) -> str:
"""Normalize a project path for comparison: strip a trailing Assets, case, sep."""
p = p.rstrip("/\\")
if p.lower().endswith("assets"):
p = p[:-6].rstrip("/\\")
return os.path.normcase(os.path.normpath(p))
def _find_reuse_status(status_dir: Path, project_path: Path) -> str | None:
"""Find a status file under status_dir whose project root matches project_path.
Matches on the full normalized project root (not just the leaf folder name) so
two projects sharing a folder name never attach to the wrong resident editor.
Refuses (returns None) rather than attach to a non-matching instance.
"""
want = _norm_project_root(str(project_path))
files = sorted(
glob.glob(str(status_dir / "unity-mcp-status-*.json")),
key=lambda p: os.path.getmtime(p) if os.path.exists(p) else 0,
reverse=True,
)
for f in files:
data = _read_status(f)
if not data:
continue
pp = data.get("project_path") or ""
if isinstance(pp, str) and pp and _norm_project_root(pp) == want:
return f
return None # refuse rather than attach to a non-matching instance
if __name__ == "__main__":
raise SystemExit(main())