#!/usr/bin/env python3 """Run isolated authenticated native Docs UI tests with bounded fixture lifetime.""" import argparse from datetime import datetime, timezone import json import os from pathlib import Path import plistlib import signal import shutil import subprocess import tempfile import time import urllib.request def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--simulator", required=True, help="Booted simulator UUID") parser.add_argument("--go", default="go", help="Go executable meeting go.mod's version") parser.add_argument("--skip-build", action="store_true", help="Use an already rebuilt review app and test runner") parser.add_argument("--smoke", action="store_true", help="Verify fixture authentication and credential installation without running editor acceptance") parser.add_argument("--only", help="One NativeUITests method to run") args = parser.parse_args() root = Path(__file__).resolve().parents[2] derived = root / "native/build/ios" stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") logs = root / "native/build" / ("review-" + stamp) logs.mkdir(parents=True, mode=0o700) print("Review logs:", logs, flush=True) def run(command, **options): return subprocess.run(command, cwd=root, check=True, **options) def sim(*arguments): return subprocess.check_output(["xcrun", "simctl", *arguments], cwd=root, text=True).strip() xcode = ["xcodebuild", "-project", "native/ios/DocsPhone.xcodeproj", "-scheme", "DocsReviewPhone", "-destination", "platform=iOS Simulator,id=" + args.simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", str(derived), "CODE_SIGNING_ALLOWED=NO"] # Build before starting the fixture: compiler time never consumes its ten-minute lifetime. if not args.skip_build: with (logs / "build.log").open("w") as log: run(["xcodegen", "generate", "--spec", "native/ios/project.yml"], stdout=log, stderr=subprocess.STDOUT) run(xcode + ["build-for-testing"], stdout=log, stderr=subprocess.STDOUT) products = derived / "Build/Products/Debug-iphonesimulator" apps = [products / "DocsReviewPhone.app", products / "DocsPhoneUITests-Runner.app"] bundles = [] for app in apps: with (app / "Info.plist").open("rb") as info: bundles.append(plistlib.load(info)["CFBundleIdentifier"]) sim("install", args.simulator, str(app)) paths = [] fixture = None tests = None with tempfile.TemporaryDirectory(prefix="oficina-docs-review-") as temporary: temporary = Path(temporary) input_path, stop_path = temporary / "input.json", temporary / "stop" environment = dict(os.environ, DOCS_NATIVE_REVIEW_INPUT=str(input_path), DOCS_NATIVE_REVIEW_STOP=str(stop_path)) try: with (logs / "fixture.log").open("w") as fixture_log: fixture = subprocess.Popen([args.go, "test", "./internal/docs", "-run", "^TestNativeReviewFixture$", "-count=1", "-timeout", "12m", "-v"], cwd=root, env=environment, stdout=fixture_log, stderr=subprocess.STDOUT, start_new_session=True) deadline = time.monotonic() + 120 configuration = None while time.monotonic() < deadline: if fixture.poll() is not None: raise RuntimeError("Fixture exited before readiness; see fixture.log") try: configuration = json.loads(input_path.read_bytes()) if configuration.get("origin") and configuration.get("credential"): break except (FileNotFoundError, json.JSONDecodeError): pass time.sleep(0.2) else: raise TimeoutError("Fixture was not ready within two minutes") # Prove the exact credential and server are live before installing them. credential = configuration["credential"] request = urllib.request.Request(configuration["origin"] + "/", headers={ "Accept": "application/json", "Cookie": credential["name"] + "=" + credential["value"]}) with urllib.request.urlopen(request, timeout=10) as response: if response.status != 200 or "documents" not in json.load(response): raise RuntimeError("Fixture's native credential could not read documents") data = input_path.read_bytes() for bundle in bundles: container = Path(sim("get_app_container", args.simulator, bundle, "data")) path = container / "Documents/review.json" path.parent.mkdir(parents=True, exist_ok=True) paths.append(path) with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "wb") as output: os.fchmod(output.fileno(), 0o600) output.write(data) print("Fixture authenticated; current app and runner containers received private credentials.", flush=True) if args.smoke: print("Fixture smoke passed; editor acceptance was not run.", flush=True) else: with (logs / "ui.log").open("w") as ui_log: selection = ["-only-testing:DocsPhoneUITests/NativeUITests/" + args.only] if args.only else [] tests = subprocess.Popen(xcode + ["-resultBundlePath", str(logs / "Review.xcresult"), "test-without-building"] + selection, cwd=root, stdout=ui_log, stderr=subprocess.STDOUT, start_new_session=True) deadline = time.monotonic() + 420 failed_at = None while tests.poll() is None: now = time.monotonic() if now >= deadline: raise TimeoutError("Native UI tests exceeded seven minutes; see ui.log") # Xcode can hang while finalizing an already-failed test. # Preserve its failure log, allow 45 seconds for the result # bundle, then let finally stop this exact owned process group. if failed_at is None: recorded = (logs / "ui.log").read_text(errors="replace") if any("Test Case '-[" in line and " failed (" in line for line in recorded.splitlines()): failed_at = now elif now - failed_at >= 45: raise RuntimeError("Native UI test failed and Xcode did not finish its result bundle; see ui.log") time.sleep(0.5) if tests.returncode != 0: raise RuntimeError("Native UI tests failed; see ui.log and Review.xcresult") print("Native UI tests passed:", logs / "Review.xcresult", flush=True) current = Path(sim("get_app_container", args.simulator, bundles[0], "data")) if list((current / "tmp").glob("oficina-docs-export-*")): raise RuntimeError("Native export left an unsealed staging directory after cancellation") finally: # A timeout still gets this cleanup; never let an outer go-test panic own it. if tests is not None and tests.poll() is None: os.killpg(tests.pid, signal.SIGTERM) try: tests.wait(timeout=10) except subprocess.TimeoutExpired: os.killpg(tests.pid, signal.SIGKILL) tests.wait() stop_path.touch() for bundle in bundles: subprocess.run(["xcrun", "simctl", "terminate", args.simulator, bundle], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: # Xcode reinstall may migrate a container; remove both old and current paths. container = Path(sim("get_app_container", args.simulator, bundle, "data")) paths.append(container / "Documents/review.json") # Synthetic fixture screenshots survive an incomplete xcresult. # These files exist only in this separate UI-test runner target. for screenshot in (container / "Documents").glob("*.review.png"): shutil.copyfile(screenshot, logs / screenshot.name) os.chmod(logs / screenshot.name, 0o600) screenshot.unlink() for staged in (container / "tmp").glob("oficina-docs-export-*"): if staged.is_dir(): shutil.rmtree(staged) else: staged.unlink() except subprocess.CalledProcessError: pass for path in paths: path.unlink(missing_ok=True) if fixture is not None: try: fixture.wait(timeout=20) except subprocess.TimeoutExpired: os.killpg(fixture.pid, signal.SIGTERM) try: fixture.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(fixture.pid, signal.SIGKILL) fixture.wait() print("Review credentials removed and fixture stopped.", flush=True) if fixture is not None and fixture.returncode != 0: raise RuntimeError("Fixture failed; see fixture.log") return 0 if __name__ == "__main__": raise SystemExit(main())