638 lines
25 KiB
YAML
638 lines
25 KiB
YAML
name: Claude GameObject API Tests (Unity live)
|
|
|
|
on: [workflow_dispatch]
|
|
|
|
permissions:
|
|
contents: read
|
|
checks: write
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3
|
|
|
|
jobs:
|
|
go-suite:
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 45
|
|
env:
|
|
JUNIT_OUT: reports/junit-go-suite.xml
|
|
MD_OUT: reports/junit-go-suite.md
|
|
|
|
steps:
|
|
# ---------- Secrets check ----------
|
|
- name: Detect secrets (outputs)
|
|
id: detect
|
|
env:
|
|
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
|
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
|
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
|
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
run: |
|
|
set -e
|
|
if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi
|
|
if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then
|
|
echo "unity_ok=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "unity_ok=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
# ---------- Python env for MCP server (uv) ----------
|
|
- uses: astral-sh/setup-uv@v4
|
|
with:
|
|
python-version: "3.11"
|
|
|
|
- name: Install MCP server
|
|
run: |
|
|
set -eux
|
|
uv venv
|
|
echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
|
|
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
|
|
if [ -f Server/pyproject.toml ]; then
|
|
uv pip install -e Server
|
|
elif [ -f Server/requirements.txt ]; then
|
|
uv pip install -r Server/requirements.txt
|
|
else
|
|
echo "No MCP Python deps found (skipping)"
|
|
fi
|
|
|
|
# --- Licensing ---
|
|
- name: Decide license sources
|
|
id: lic
|
|
shell: bash
|
|
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:-}" ]] && use_ebl=true
|
|
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
|
|
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"
|
|
echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$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 head -c 100 "$f" | grep -qi '<\?xml'; then
|
|
mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses"
|
|
mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml"
|
|
echo "ok=false" >> "$GITHUB_OUTPUT"
|
|
elif 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
|
|
|
|
- name: Activate Unity (EBL via container - host-mount)
|
|
if: steps.lic.outputs.use_ebl == 'true'
|
|
shell: bash
|
|
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 '
|
|
set -euxo pipefail
|
|
if [[ -n "${UNITY_SERIAL:-}" ]]; then
|
|
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
|
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
|
|
else
|
|
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
|
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true
|
|
fi
|
|
ls -la /root/.config/unity3d/Unity/licenses || true
|
|
'
|
|
|
|
if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then
|
|
if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then
|
|
echo "EBL entitlement not found; proceeding with ULF-only (ok=true)."
|
|
else
|
|
echo "No entitlement produced and no valid ULF; cannot continue." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# ---------- Warm up project ----------
|
|
- name: Warm up project (import Library once)
|
|
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
|
|
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 "${{ 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
|
|
|
|
# ---------- Clean old MCP status ----------
|
|
- name: Clean old MCP status
|
|
run: |
|
|
set -eux
|
|
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
|
|
rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true
|
|
|
|
# ---------- Start headless Unity ----------
|
|
- name: Start Unity (persistent bridge)
|
|
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
|
|
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
|
|
|
|
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
|
|
docker rm -f unity-mcp >/dev/null 2>&1 || true
|
|
docker run -d --name unity-mcp --network host \
|
|
-e HOME=/root \
|
|
-e UNITY_MCP_ALLOW_BATCH=1 \
|
|
-e UNITY_MCP_STATUS_DIR="${{ github.workspace }}/.unity-mcp" \
|
|
-e UNITY_MCP_BIND_HOST=127.0.0.1 \
|
|
-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 /root/.config/unity3d/Editor.log \
|
|
-stackTraceLogType Full \
|
|
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
|
|
"${manual_args[@]}" \
|
|
-executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi
|
|
|
|
# ---------- Wait for Unity bridge ----------
|
|
- name: Wait for Unity bridge (robust)
|
|
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
deadline=$((SECONDS+600))
|
|
fatal_after=$((SECONDS+120))
|
|
ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)'
|
|
license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)'
|
|
|
|
while [ $SECONDS -lt $deadline ]; do
|
|
logs="$(docker logs unity-mcp 2>&1 || true)"
|
|
port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)"
|
|
if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then
|
|
echo "Bridge ready on port $port"
|
|
docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true
|
|
exit 0
|
|
fi
|
|
if echo "$logs" | grep -qiE "$ok_pat"; then
|
|
echo "Bridge ready (log markers)"
|
|
docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true
|
|
exit 0
|
|
fi
|
|
if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then
|
|
echo "::error::Fatal licensing signal detected after warm-up"
|
|
echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
|
exit 1
|
|
fi
|
|
st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)"
|
|
if [[ "$st" != "running" ]]; then
|
|
echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
echo "::error::Bridge not ready before deadline"
|
|
docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
|
exit 1
|
|
|
|
- name: Pin Claude tool permissions
|
|
run: |
|
|
set -eux
|
|
mkdir -p .claude
|
|
cat > .claude/settings.json <<'JSON'
|
|
{
|
|
"permissions": {
|
|
"allow": [
|
|
"mcp__unity",
|
|
"Edit(reports/**)",
|
|
"MultiEdit(reports/**)"
|
|
],
|
|
"deny": [
|
|
"Bash",
|
|
"WebFetch",
|
|
"WebSearch",
|
|
"Task",
|
|
"TodoWrite",
|
|
"NotebookEdit",
|
|
"NotebookRead"
|
|
]
|
|
}
|
|
}
|
|
JSON
|
|
|
|
- name: Prepare reports
|
|
run: |
|
|
set -eux
|
|
rm -f reports/*.xml reports/*.md || true
|
|
mkdir -p reports
|
|
|
|
- name: Create report skeletons
|
|
run: |
|
|
set -eu
|
|
cat > "$JUNIT_OUT" <<'XML'
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<testsuites><testsuite name="UnityMCP.GO-T" tests="1" failures="1" errors="0" skipped="0" time="0">
|
|
<testcase name="GO-Suite.Bootstrap" classname="UnityMCP.GO-T">
|
|
<failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure>
|
|
</testcase>
|
|
</testsuite></testsuites>
|
|
XML
|
|
printf '# Unity GameObject API Test Results\n\n' > "$MD_OUT"
|
|
|
|
- name: Verify Unity bridge status
|
|
run: |
|
|
set -euxo pipefail
|
|
shopt -s nullglob
|
|
status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json)
|
|
if ((${#status_files[@]})); then
|
|
first_status="${status_files[0]}"
|
|
fname="$(basename "$first_status")"
|
|
hash_part="${fname%.json}"; hash_part="${hash_part#unity-mcp-status-}"
|
|
proj="$(jq -r '.project_name // empty' "$first_status" || true)"
|
|
if [[ -n "${proj:-}" && -n "${hash_part:-}" ]]; then
|
|
echo "UNITY_MCP_DEFAULT_INSTANCE=${proj}@${hash_part}" >> "$GITHUB_ENV"
|
|
echo "Default instance set to ${proj}@${hash_part}"
|
|
fi
|
|
fi
|
|
|
|
- name: Write MCP config
|
|
run: |
|
|
set -eux
|
|
mkdir -p .claude
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
workspace = os.environ["GITHUB_WORKSPACE"]
|
|
default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip()
|
|
|
|
cfg = {
|
|
"mcpServers": {
|
|
"unity": {
|
|
"args": [
|
|
"run",
|
|
"--active",
|
|
"--directory",
|
|
"Server",
|
|
"mcp-for-unity",
|
|
"--transport",
|
|
"stdio",
|
|
],
|
|
"transport": {"type": "stdio"},
|
|
"env": {
|
|
"PYTHONUNBUFFERED": "1",
|
|
"MCP_LOG_LEVEL": "debug",
|
|
"UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests",
|
|
"UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp",
|
|
"UNITY_MCP_HOST": "127.0.0.1",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
unity = cfg["mcpServers"]["unity"]
|
|
if default_inst:
|
|
unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst
|
|
if "--default-instance" not in unity["args"]:
|
|
unity["args"] += ["--default-instance", default_inst]
|
|
|
|
runner_script = Path(".claude/run-unity-mcp.sh")
|
|
workspace_path = Path(workspace)
|
|
uv_candidate = workspace_path / ".venv" / "bin" / "uv"
|
|
uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv"
|
|
script = textwrap.dedent(f"""\
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log"
|
|
mkdir -p "$(dirname "$LOG")"
|
|
echo "" >> "$LOG"
|
|
echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG"
|
|
exec {uv_cmd} "$@" 2>> "$LOG"
|
|
""")
|
|
runner_script.write_text(script)
|
|
runner_script.chmod(0o755)
|
|
|
|
unity["command"] = runner_script.resolve().as_posix()
|
|
|
|
path = Path(".claude/mcp.json")
|
|
path.write_text(json.dumps(cfg, indent=2) + "\n")
|
|
print(f"Wrote {path} and {runner_script}")
|
|
PY
|
|
|
|
# ---------- Run Claude GO pass ----------
|
|
- name: Run Claude GO pass
|
|
uses: anthropics/claude-code-base-action@beta
|
|
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
|
|
continue-on-error: true
|
|
env:
|
|
UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }}
|
|
with:
|
|
use_node_cache: false
|
|
prompt_file: .claude/prompts/nl-gameobject-suite.md
|
|
mcp_config: .claude/mcp.json
|
|
settings: .claude/settings.json
|
|
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
|
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
|
model: claude-haiku-4-5-20251001
|
|
fallback_model: claude-sonnet-4-5-20250929
|
|
append_system_prompt: |
|
|
You are running the GameObject API tests.
|
|
- Emit exactly GO-0, GO-1, GO-2, GO-3, GO-4, GO-5.
|
|
- Write each to reports/${ID}_results.xml.
|
|
- Stop after GO-5_results.xml is written.
|
|
timeout_minutes: "25"
|
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
|
|
# ---------- Backfill missing tests ----------
|
|
- name: Backfill missing GO tests
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
python3 - <<'PY'
|
|
from pathlib import Path
|
|
import xml.etree.ElementTree as ET
|
|
import re
|
|
|
|
DESIRED = ["GO-0","GO-1","GO-2","GO-3","GO-4","GO-5"]
|
|
seen = set()
|
|
|
|
def id_from_filename(p: Path):
|
|
n = p.name
|
|
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
|
|
if m:
|
|
return f"GO-{int(m.group(1))}"
|
|
return None
|
|
|
|
for p in Path("reports").glob("*_results.xml"):
|
|
fid = id_from_filename(p)
|
|
if fid in DESIRED:
|
|
seen.add(fid)
|
|
|
|
Path("reports").mkdir(parents=True, exist_ok=True)
|
|
for d in DESIRED:
|
|
if d in seen:
|
|
continue
|
|
frag = Path(f"reports/{d}_results.xml")
|
|
tc = ET.Element("testcase", {"classname":"UnityMCP.GO-T", "name": d})
|
|
fail = ET.SubElement(tc, "failure", {"message":"not produced"})
|
|
fail.text = "The agent did not emit a fragment for this test."
|
|
ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False)
|
|
print(f"backfill: {d}")
|
|
PY
|
|
|
|
# ---------- Merge fragments into JUnit ----------
|
|
- name: Assemble JUnit
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
python3 - <<'PY'
|
|
from pathlib import Path
|
|
import xml.etree.ElementTree as ET
|
|
import re, os
|
|
|
|
def localname(tag: str) -> str:
|
|
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
|
|
|
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml'))
|
|
if not src.exists():
|
|
raise SystemExit(0)
|
|
|
|
tree = ET.parse(src)
|
|
root = tree.getroot()
|
|
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
|
|
if suite is None:
|
|
raise SystemExit(0)
|
|
|
|
def id_from_filename(p: Path):
|
|
n = p.name
|
|
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
|
|
if m:
|
|
return f"GO-{int(m.group(1))}"
|
|
return None
|
|
|
|
fragments = sorted(Path('reports').glob('GO-*_results.xml'))
|
|
added = 0
|
|
|
|
for frag in fragments:
|
|
try:
|
|
froot = ET.parse(frag).getroot()
|
|
if localname(froot.tag) == 'testcase':
|
|
suite.append(froot)
|
|
added += 1
|
|
except Exception:
|
|
pass
|
|
|
|
if added:
|
|
for tc in list(suite.findall('.//testcase')):
|
|
if (tc.get('name') or '') == 'GO-Suite.Bootstrap':
|
|
suite.remove(tc)
|
|
testcases = suite.findall('.//testcase')
|
|
failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None))
|
|
suite.set('tests', str(len(testcases)))
|
|
suite.set('failures', str(failures_cnt))
|
|
suite.set('errors', '0')
|
|
suite.set('skipped', '0')
|
|
tree.write(src, encoding='utf-8', xml_declaration=True)
|
|
print(f"Appended {added} testcase(s).")
|
|
PY
|
|
|
|
# ---------- Build markdown summary ----------
|
|
- name: Build markdown summary
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
python3 - <<'PY'
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
import os, html, re
|
|
|
|
def localname(tag: str) -> str:
|
|
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
|
|
|
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml'))
|
|
md_out = Path(os.environ.get('MD_OUT', 'reports/junit-go-suite.md'))
|
|
md_out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not src.exists():
|
|
md_out.write_text("# Unity GameObject API Test Results\n\n(No JUnit found)\n", encoding='utf-8')
|
|
raise SystemExit(0)
|
|
|
|
tree = ET.parse(src)
|
|
root = tree.getroot()
|
|
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
|
|
cases = [] if suite is None else list(suite.findall('.//testcase'))
|
|
|
|
desired = ['GO-0','GO-1','GO-2','GO-3','GO-4','GO-5']
|
|
default_titles = {
|
|
'GO-0': 'Hierarchy with ComponentTypes',
|
|
'GO-1': 'Find GameObjects Tool',
|
|
'GO-2': 'GameObject Resource Read',
|
|
'GO-3': 'Components Resource Read',
|
|
'GO-4': 'Manage Components Tool',
|
|
'GO-5': 'Deprecation Warnings',
|
|
}
|
|
|
|
def id_from_case(tc):
|
|
n = (tc.get('name') or '')
|
|
m = re.match(r'\s*(GO-\d+)\b', n)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
id_status = {}
|
|
for tc in cases:
|
|
tid = id_from_case(tc)
|
|
if not tid or tid not in desired or tid in id_status:
|
|
continue
|
|
ok = (tc.find('failure') is None and tc.find('error') is None)
|
|
id_status[tid] = ok
|
|
|
|
total = len(cases)
|
|
failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None))
|
|
passed = total - failures
|
|
|
|
lines = [
|
|
'# Unity GameObject API Test Results',
|
|
'',
|
|
f'Totals: {passed} passed, {failures} failed, {total} total',
|
|
'',
|
|
'## Test Checklist'
|
|
]
|
|
for p in desired:
|
|
st = id_status.get(p, None)
|
|
label = f"{p} — {default_titles.get(p, '')}"
|
|
lines.append(f"- [x] {label}" if st is True else (f"- [ ] {label} (fail)" if st is False else f"- [ ] {label} (not run)"))
|
|
lines.append('')
|
|
|
|
lines.append('## Test Details')
|
|
for tc in cases:
|
|
tid = id_from_case(tc)
|
|
if not tid:
|
|
continue
|
|
title = tc.get('name') or tid
|
|
ok = (tc.find('failure') is None and tc.find('error') is None)
|
|
badge = "PASS" if ok else "FAIL"
|
|
lines.append(f"### {title} — {badge}")
|
|
so = tc.find('system-out')
|
|
text = '' if so is None or so.text is None else html.unescape(so.text.strip())
|
|
if text:
|
|
lines += ['```', text[:2000], '```']
|
|
else:
|
|
lines.append('(no system-out)')
|
|
node = tc.find('failure') or tc.find('error')
|
|
if node is not None:
|
|
msg = (node.get('message') or '').strip()
|
|
if msg:
|
|
lines.append(f"- Message: {msg}")
|
|
lines.append('')
|
|
|
|
md_out.write_text('\n'.join(lines), encoding='utf-8')
|
|
PY
|
|
|
|
- name: GO details -> Job Summary
|
|
if: always()
|
|
run: |
|
|
echo "## Unity GameObject API Tests — Summary" >> $GITHUB_STEP_SUMMARY
|
|
python3 - <<'PY' >> $GITHUB_STEP_SUMMARY
|
|
from pathlib import Path
|
|
p = Path('reports/junit-go-suite.md')
|
|
if p.exists():
|
|
text = p.read_bytes().decode('utf-8', 'replace')
|
|
print(text[:65000])
|
|
else:
|
|
print("_No markdown report found._")
|
|
PY
|
|
|
|
- name: Publish JUnit report
|
|
if: always()
|
|
uses: mikepenz/action-junit-report@v5
|
|
with:
|
|
report_paths: "${{ env.JUNIT_OUT }}"
|
|
include_passed: true
|
|
detailed_summary: true
|
|
annotate_notice: true
|
|
require_tests: false
|
|
fail_on_parse_error: true
|
|
|
|
- name: Upload artifacts
|
|
if: always()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: claude-go-suite-artifacts
|
|
path: |
|
|
${{ env.JUNIT_OUT }}
|
|
${{ env.MD_OUT }}
|
|
reports/*_results.xml
|
|
retention-days: 7
|
|
|
|
# ---------- Cleanup ----------
|
|
- name: Stop Unity
|
|
if: always()
|
|
run: |
|
|
docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
|
|
docker rm -f unity-mcp || true
|
|
|
|
- name: Return Pro license (if used)
|
|
if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true'
|
|
uses: game-ci/unity-return-license@v2
|
|
continue-on-error: true
|
|
env:
|
|
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
|
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
|
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
|
|