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
98 changes: 26 additions & 72 deletions .github/workflows/e2e-bridge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ on:
- "Server/src/**"
- "Server/tests/e2e/**"
- "tools/local_harness.py"
- "tools/ci_unity_license.py"
- "tools/tests/test_ci_unity_license.py"
- ".github/workflows/e2e-bridge.yml"

permissions:
Expand Down Expand Up @@ -50,7 +52,9 @@ jobs:
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -e
if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ] && [ -n "$UNITY_SERIAL" ]; }; then
# Only absent secrets skip a fork run. Partial configuration must fail
# explicitly in prepare, rather than silently look like missing access.
if [ -n "$UNITY_LICENSE$UNITY_EMAIL$UNITY_PASSWORD$UNITY_SERIAL" ]; then
Comment thread
coderabbitai[bot] marked this conversation as resolved.
echo "unity_ok=true" >> "$GITHUB_OUTPUT"
else
echo "unity_ok=false" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -90,89 +94,39 @@ jobs:
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
uv pip install -e Server

# --- License staging (mirrors claude-nl-suite.yml) ---
- name: Decide license sources
id: lic
shell: bash
# Activate with the same pinned GameCI implementation that runs our tests.
# Personal account credentials do not require a professional serial key.
- name: Prepare Unity license and activation helpers
id: prepare_license
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -eu
use_ulf=false; use_ebl=false
[[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true
[[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" && -n "${UNITY_SERIAL:-}" ]] && use_ebl=true
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"

- name: Stage Unity .ulf license (from secret)
if: steps.lic.outputs.use_ulf == 'true'
id: ulf
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
shell: bash
run: |
set -eu
mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity"
f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf"
if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then
printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f"
else
printf "%s" "$UNITY_LICENSE" > "$f"
fi
chmod 600 "$f" || true
if grep -qi '<Signature>' "$f"; then
cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf"
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
fi
run: python3 tools/ci_unity_license.py prepare

- name: Activate Unity (EBL via container)
if: steps.lic.outputs.use_ebl == 'true'
shell: bash
- name: Activate Unity license
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local"
docker run --rm --network host \
-e HOME=/root -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
"$UNITY_IMAGE" bash -lc '
# No -x here: xtrace would echo the expanded -password/-serial arguments into
# the job log, and GitHub secret masking is a last line of defence, not a design.
set -euo pipefail
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
'
run: python3 tools/ci_unity_license.py activate

- name: Warm up project (import Library once)
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
manual_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
docker run --rm --network host \
-e HOME=/root \
-v "$RUNNER_TEMP/unity-machine-id:/etc/machine-id:ro" \
-v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
-v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
"${manual_args[@]}" -quit
-quit

- name: Clean old MCP status
run: |
Expand All @@ -184,32 +138,32 @@ jobs:
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
# In --ci mode the harness drives the DockerLauncher: it runs the same
# docker container (repo .unity-mcp status dir, docker liveness/teardown,
# log redaction), waits on the status file, derives the instance, then
# runs the smoke + EditMode + PlayMode legs over the bridge.
license_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
license_args=(--editor-arg -manualLicenseFile \
--editor-arg "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
python3 tools/local_harness.py --ci \
--legs smoke,editmode,playmode \
--project-path TestProjects/UnityMCPTests \
--reports reports \
"${license_args[@]}"
--reports reports

- name: Unity logs on failure
if: failure()
run: docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true
- name: Return Unity license seat
if: always() && steps.prepare_license.outcome == 'success'
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: python3 tools/ci_unity_license.py return

- name: Upload E2E report
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-bridge-report
path: reports/junit-*.xml
# The harness snapshots redacted Editor logs before removing its container.
path: |
reports/junit-*.xml
reports/unity-editor-*.log
if-no-files-found: ignore
72 changes: 27 additions & 45 deletions .github/workflows/unity-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ on:
- MCPForUnity/Editor/**
- MCPForUnity/Runtime/**
- .github/workflows/unity-tests.yml
- tools/check_unity_test_results.py
# Same-repo PRs get a unity-tests status check on every open / push via this trigger
# (mirrors python-tests.yml). Fork PRs ALSO fire this trigger but run in the fork's
# context without secrets — the `license` gate job writes unity_ok=false and the test
Expand All @@ -40,6 +41,7 @@ on:
- MCPForUnity/Editor/**
- MCPForUnity/Runtime/**
- .github/workflows/unity-tests.yml
- tools/check_unity_test_results.py

# Dedup runs for the same branch across push / pull_request / workflow_call.
# Same-repo PRs would otherwise fire both push (on the branch SHA) AND pull_request (on the PR);
Expand Down Expand Up @@ -166,9 +168,13 @@ jobs:
Library-

# Run domain reload tests first (they're [Explicit] so need explicit category)
# Both runner steps pin the action and its CLI. The floating v4 tag and the CLI's "latest"
# release changed behavior under this workflow once (red beta since 2026-09-05); a pinned
# cliVersion also skips the unauthenticated releases/latest lookup. Bump both on purpose.
- name: Run domain reload tests
uses: game-ci/unity-test-runner@v4
uses: game-ci/unity-test-runner@32e57712352b500e17974b245a6dce9e11a73213 # v4
id: domain-tests
continue-on-error: true
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
Expand All @@ -179,9 +185,20 @@ jobs:
unityVersion: ${{ matrix.unityVersion }}
testMode: ${{ matrix.testMode }}
customParameters: -testCategory domain_reload
cliVersion: v0.1.69
# Results are gated locally; this read-only job cannot publish Checks API results.
githubToken: ""
artifactsPath: artifacts/domain-reload

# Preserve runner failures while reporting any NUnit failure details or missing results.
- name: Check domain reload test results
env:
RESULTS_XML: artifacts/domain-reload/${{ matrix.testMode }}-results.xml
TEST_RUN_OUTCOME: ${{ steps.domain-tests.outcome }}
run: python3 tools/check_unity_test_results.py "$RESULTS_XML" --runner-outcome "$TEST_RUN_OUTCOME"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Run tests
uses: game-ci/unity-test-runner@v4
uses: game-ci/unity-test-runner@32e57712352b500e17974b245a6dce9e11a73213 # v4
id: tests
continue-on-error: true
env:
Expand All @@ -193,10 +210,15 @@ jobs:
projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }}
testMode: ${{ matrix.testMode }}
cliVersion: v0.1.69
githubToken: ""
# Keep the preceding domain-reload XML out of the regular suite's result gate.
artifactsPath: artifacts/editmode

- name: Check test results
env:
ARTIFACTS_PATH: ${{ steps.tests.outputs.artifactsPath }}
TEST_RUN_OUTCOME: ${{ steps.tests.outcome }}
run: |
set -euo pipefail
# `|| true` so a missing $ARTIFACTS_PATH (Unity crashed before producing any) doesn't trip
Expand All @@ -206,50 +228,10 @@ jobs:
echo "::error::No test results XML found — Unity may have crashed"
exit 1
fi
python3 - "$RESULTS_XML" <<'PY'
import sys, xml.etree.ElementTree as ET
# Escape workflow-command payloads so test-controlled XML can't break annotation
# rendering or inject extra workflow commands.
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
def esc_data(s):
return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
def esc_prop(s):
return esc_data(s).replace(":", "%3A").replace(",", "%2C")
root = ET.parse(sys.argv[1]).getroot()
totals = root.attrib
passed = totals.get("passed", "?")
failed = totals.get("failed", "?")
total = totals.get("total", "?")
incon = totals.get("inconclusive", "?")
skipped = totals.get("skipped", "?")
print(f"Results: {passed} passed, {failed} failed, {incon} inconclusive, {skipped} skipped (total: {total})")
fails = [tc for tc in root.iter("test-case") if tc.attrib.get("result") == "Failed"]
if not fails:
sys.exit(0)
# Surface every failure inline so a CI watcher doesn't need to download the NUnit XML artifact.
for tc in fails:
name = tc.attrib.get("fullname") or tc.attrib.get("name") or "<unknown>"
f = tc.find("failure")
msg = (f.findtext("message") or "").strip() if f is not None else ""
stack = (f.findtext("stack-trace") or "").strip() if f is not None else ""
# First line of the message becomes the GitHub annotation title.
first_line = msg.splitlines()[0] if msg else "(no message)"
# GitHub annotations don't render multi-line bodies, so emit the full failure inside a collapsible group.
print(f"::error title=Failed: {esc_prop(name)}::{esc_data(first_line)}")
print(f"::group::Failure details — {esc_data(name)}")
if msg:
print("Message:")
print(msg)
if stack:
print("Stack trace:")
print(stack)
print("::endgroup::")
print(f"::error::{len(fails)} test(s) failed")
sys.exit(1)
PY
python3 tools/check_unity_test_results.py "$RESULTS_XML" --runner-outcome "$TEST_RUN_OUTCOME"

- uses: actions/upload-artifact@v4
if: always() && steps.tests.outcome != 'skipped'
if: always() && steps.domain-tests.outcome != 'skipped'
with:
name: Test results for ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }}
path: ${{ steps.tests.outputs.artifactsPath }}
path: artifacts/
67 changes: 65 additions & 2 deletions MCPForUnity/Editor/Services/TestJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ internal sealed class TestJob
/// <summary>
/// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs.
/// </summary>
[InitializeOnLoad]
internal static class TestJobManager
{
// Keep this small to avoid ballooning payloads during polling.
Expand All @@ -69,6 +70,42 @@ static TestJobManager()
{
// Restore after domain reloads (e.g., compilation while a job is running).
TryRestoreFromSessionState();
AssemblyReloadEvents.beforeAssemblyReload += BeforeAssemblyReload;
RestoreRunningJobCallbacks();
}

private static void BeforeAssemblyReload()
{
// Progress callbacks are normally throttled. Flush the last update before the
// managed domain (and the original RunTestsAsync task) is discarded.
PersistToSessionState(force: true);
}

private static void RestoreRunningJobCallbacks()
{
TestJob job;
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) ||
!Jobs.TryGetValue(_currentJobId, out job) || job.Status != TestJobStatus.Running)
{
return;
}
}

try
{
// Polling a restored job never otherwise touches the lazy test service.
// Re-register callbacks now, before the Test Runner resumes after reload.
if (MCPServiceLocator.Tests is TestRunnerService service)
{
service.ResumeJobAfterReload(job.JobId, job.Mode);
}
}
catch (Exception ex)
{
McpLog.Warn($"[TestJobManager] Failed to restore test callbacks: {ex.Message}");
}
}

public static string CurrentJobId
Expand Down Expand Up @@ -381,7 +418,33 @@ public static void FinalizeCurrentJobFromRunFinished(TestRunResult resultPayload
: TestJobStatus.Succeeded;
job.Error = null;
job.Result = resultPayload;
if (resultPayload != null)
{
job.TotalTests = resultPayload.Total;
job.CompletedTests = resultPayload.Total;
}
job.CurrentTestFullName = null;
job.CurrentTestStartedUnixMs = null;
_currentJobId = null;
}
PersistToSessionState(force: true);
}

internal static void FinalizeCurrentJobFromRunError(string message)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
{
return;
}
job.Status = TestJobStatus.Failed;
job.Error = message;
job.LastUpdateUnixMs = now;
job.FinishedUnixMs = now;
job.CurrentTestFullName = null;
job.CurrentTestStartedUnixMs = null;
_currentJobId = null;
}
PersistToSessionState(force: true);
Expand Down Expand Up @@ -539,7 +602,7 @@ internal static object ToSerializable(TestJob job, bool includeDetails, bool inc
}

object resultPayload = null;
if (job.Status == TestJobStatus.Succeeded && job.Result != null)
if (job.Status != TestJobStatus.Running && job.Result != null)
{
resultPayload = job.Result.ToSerializable(job.Mode, includeDetails, includeFailedTests);
}
Expand All @@ -562,6 +625,7 @@ internal static object ToSerializable(TestJob job, bool includeDetails, bool inc
last_finished_unix_ms = job.LastFinishedUnixMs,
stuck_suspected = IsStuck(job),
editor_is_focused = InternalEditorUtility.isApplicationActive,
run_in_background = UnityEngine.Application.runInBackground,
blocked_reason = GetBlockedReason(job),
failures_so_far = BuildFailuresPayload(job.FailuresSoFar),
failures_capped = (job.FailuresSoFar != null && job.FailuresSoFar.Count >= FailureCap)
Expand Down Expand Up @@ -685,4 +749,3 @@ private static void FinalizeFromTask(string jobId, Task<TestRunResult> task)
}
}
}

Loading
Loading