| 1 | #!/usr/bin/env python3 |
| 2 | """Run isolated authenticated native Docs UI tests with bounded fixture lifetime.""" |
| 3 | |
| 4 | import argparse |
| 5 | from datetime import datetime, timezone |
| 6 | import json |
| 7 | import os |
| 8 | from pathlib import Path |
| 9 | import plistlib |
| 10 | import signal |
| 11 | import shutil |
| 12 | import subprocess |
| 13 | import tempfile |
| 14 | import time |
| 15 | import urllib.request |
| 16 | |
| 17 | |
| 18 | def main(): |
| 19 | parser = argparse.ArgumentParser(description=__doc__) |
| 20 | parser.add_argument("--simulator", required=True, help="Booted simulator UUID") |
| 21 | parser.add_argument("--go", default="go", help="Go executable meeting go.mod's version") |
| 22 | parser.add_argument("--skip-build", action="store_true", help="Use an already rebuilt review app and test runner") |
| 23 | parser.add_argument("--smoke", action="store_true", help="Verify fixture authentication and credential installation without running editor acceptance") |
| 24 | parser.add_argument("--only", help="One NativeUITests method to run") |
| 25 | args = parser.parse_args() |
| 26 | root = Path(__file__).resolve().parents[2] |
| 27 | derived = root / "native/build/ios" |
| 28 | stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") |
| 29 | logs = root / "native/build" / ("review-" + stamp) |
| 30 | logs.mkdir(parents=True, mode=0o700) |
| 31 | print("Review logs:", logs, flush=True) |
| 32 | |
| 33 | def run(command, **options): |
| 34 | return subprocess.run(command, cwd=root, check=True, **options) |
| 35 | |
| 36 | def sim(*arguments): |
| 37 | return subprocess.check_output(["xcrun", "simctl", *arguments], cwd=root, text=True).strip() |
| 38 | |
| 39 | xcode = ["xcodebuild", "-project", "native/ios/DocsPhone.xcodeproj", "-scheme", "DocsReviewPhone", |
| 40 | "-destination", "platform=iOS Simulator,id=" + args.simulator, |
| 41 | "-parallel-testing-enabled", "NO", "-derivedDataPath", str(derived), "CODE_SIGNING_ALLOWED=NO"] |
| 42 | # Build before starting the fixture: compiler time never consumes its ten-minute lifetime. |
| 43 | if not args.skip_build: |
| 44 | with (logs / "build.log").open("w") as log: |
| 45 | run(["xcodegen", "generate", "--spec", "native/ios/project.yml"], stdout=log, stderr=subprocess.STDOUT) |
| 46 | run(xcode + ["build-for-testing"], stdout=log, stderr=subprocess.STDOUT) |
| 47 | |
| 48 | products = derived / "Build/Products/Debug-iphonesimulator" |
| 49 | apps = [products / "DocsReviewPhone.app", products / "DocsPhoneUITests-Runner.app"] |
| 50 | bundles = [] |
| 51 | for app in apps: |
| 52 | with (app / "Info.plist").open("rb") as info: |
| 53 | bundles.append(plistlib.load(info)["CFBundleIdentifier"]) |
| 54 | sim("install", args.simulator, str(app)) |
| 55 | |
| 56 | paths = [] |
| 57 | fixture = None |
| 58 | tests = None |
| 59 | with tempfile.TemporaryDirectory(prefix="oficina-docs-review-") as temporary: |
| 60 | temporary = Path(temporary) |
| 61 | input_path, stop_path = temporary / "input.json", temporary / "stop" |
| 62 | environment = dict(os.environ, DOCS_NATIVE_REVIEW_INPUT=str(input_path), DOCS_NATIVE_REVIEW_STOP=str(stop_path)) |
| 63 | try: |
| 64 | with (logs / "fixture.log").open("w") as fixture_log: |
| 65 | fixture = subprocess.Popen([args.go, "test", "./internal/docs", "-run", "^TestNativeReviewFixture$", |
| 66 | "-count=1", "-timeout", "12m", "-v"], cwd=root, env=environment, |
| 67 | stdout=fixture_log, stderr=subprocess.STDOUT, start_new_session=True) |
| 68 | deadline = time.monotonic() + 120 |
| 69 | configuration = None |
| 70 | while time.monotonic() < deadline: |
| 71 | if fixture.poll() is not None: |
| 72 | raise RuntimeError("Fixture exited before readiness; see fixture.log") |
| 73 | try: |
| 74 | configuration = json.loads(input_path.read_bytes()) |
| 75 | if configuration.get("origin") and configuration.get("credential"): |
| 76 | break |
| 77 | except (FileNotFoundError, json.JSONDecodeError): |
| 78 | pass |
| 79 | time.sleep(0.2) |
| 80 | else: |
| 81 | raise TimeoutError("Fixture was not ready within two minutes") |
| 82 | |
| 83 | # Prove the exact credential and server are live before installing them. |
| 84 | credential = configuration["credential"] |
| 85 | request = urllib.request.Request(configuration["origin"] + "/", headers={ |
| 86 | "Accept": "application/json", "Cookie": credential["name"] + "=" + credential["value"]}) |
| 87 | with urllib.request.urlopen(request, timeout=10) as response: |
| 88 | if response.status != 200 or "documents" not in json.load(response): |
| 89 | raise RuntimeError("Fixture's native credential could not read documents") |
| 90 | data = input_path.read_bytes() |
| 91 | for bundle in bundles: |
| 92 | container = Path(sim("get_app_container", args.simulator, bundle, "data")) |
| 93 | path = container / "Documents/review.json" |
| 94 | path.parent.mkdir(parents=True, exist_ok=True) |
| 95 | paths.append(path) |
| 96 | with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "wb") as output: |
| 97 | os.fchmod(output.fileno(), 0o600) |
| 98 | output.write(data) |
| 99 | print("Fixture authenticated; current app and runner containers received private credentials.", flush=True) |
| 100 | if args.smoke: |
| 101 | print("Fixture smoke passed; editor acceptance was not run.", flush=True) |
| 102 | else: |
| 103 | with (logs / "ui.log").open("w") as ui_log: |
| 104 | selection = ["-only-testing:DocsPhoneUITests/NativeUITests/" + args.only] if args.only else [] |
| 105 | tests = subprocess.Popen(xcode + ["-resultBundlePath", str(logs / "Review.xcresult"), "test-without-building"] + selection, |
| 106 | cwd=root, stdout=ui_log, stderr=subprocess.STDOUT, start_new_session=True) |
| 107 | deadline = time.monotonic() + 420 |
| 108 | failed_at = None |
| 109 | while tests.poll() is None: |
| 110 | now = time.monotonic() |
| 111 | if now >= deadline: |
| 112 | raise TimeoutError("Native UI tests exceeded seven minutes; see ui.log") |
| 113 | # Xcode can hang while finalizing an already-failed test. |
| 114 | # Preserve its failure log, allow 45 seconds for the result |
| 115 | # bundle, then let finally stop this exact owned process group. |
| 116 | if failed_at is None: |
| 117 | recorded = (logs / "ui.log").read_text(errors="replace") |
| 118 | if any("Test Case '-[" in line and " failed (" in line for line in recorded.splitlines()): |
| 119 | failed_at = now |
| 120 | elif now - failed_at >= 45: |
| 121 | raise RuntimeError("Native UI test failed and Xcode did not finish its result bundle; see ui.log") |
| 122 | time.sleep(0.5) |
| 123 | if tests.returncode != 0: |
| 124 | raise RuntimeError("Native UI tests failed; see ui.log and Review.xcresult") |
| 125 | print("Native UI tests passed:", logs / "Review.xcresult", flush=True) |
| 126 | current = Path(sim("get_app_container", args.simulator, bundles[0], "data")) |
| 127 | if list((current / "tmp").glob("oficina-docs-export-*")): |
| 128 | raise RuntimeError("Native export left an unsealed staging directory after cancellation") |
| 129 | finally: |
| 130 | # A timeout still gets this cleanup; never let an outer go-test panic own it. |
| 131 | if tests is not None and tests.poll() is None: |
| 132 | os.killpg(tests.pid, signal.SIGTERM) |
| 133 | try: |
| 134 | tests.wait(timeout=10) |
| 135 | except subprocess.TimeoutExpired: |
| 136 | os.killpg(tests.pid, signal.SIGKILL) |
| 137 | tests.wait() |
| 138 | stop_path.touch() |
| 139 | for bundle in bundles: |
| 140 | subprocess.run(["xcrun", "simctl", "terminate", args.simulator, bundle], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 141 | try: |
| 142 | # Xcode reinstall may migrate a container; remove both old and current paths. |
| 143 | container = Path(sim("get_app_container", args.simulator, bundle, "data")) |
| 144 | paths.append(container / "Documents/review.json") |
| 145 | # Synthetic fixture screenshots survive an incomplete xcresult. |
| 146 | # These files exist only in this separate UI-test runner target. |
| 147 | for screenshot in (container / "Documents").glob("*.review.png"): |
| 148 | shutil.copyfile(screenshot, logs / screenshot.name) |
| 149 | os.chmod(logs / screenshot.name, 0o600) |
| 150 | screenshot.unlink() |
| 151 | for staged in (container / "tmp").glob("oficina-docs-export-*"): |
| 152 | if staged.is_dir(): shutil.rmtree(staged) |
| 153 | else: staged.unlink() |
| 154 | except subprocess.CalledProcessError: |
| 155 | pass |
| 156 | for path in paths: |
| 157 | path.unlink(missing_ok=True) |
| 158 | if fixture is not None: |
| 159 | try: |
| 160 | fixture.wait(timeout=20) |
| 161 | except subprocess.TimeoutExpired: |
| 162 | os.killpg(fixture.pid, signal.SIGTERM) |
| 163 | try: |
| 164 | fixture.wait(timeout=5) |
| 165 | except subprocess.TimeoutExpired: |
| 166 | os.killpg(fixture.pid, signal.SIGKILL) |
| 167 | fixture.wait() |
| 168 | print("Review credentials removed and fixture stopped.", flush=True) |
| 169 | if fixture is not None and fixture.returncode != 0: |
| 170 | raise RuntimeError("Fixture failed; see fixture.log") |
| 171 | return 0 |
| 172 | |
| 173 | |
| 174 | if __name__ == "__main__": |
| 175 | raise SystemExit(main()) |
| 176 | |