oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env python3
2"""Run isolated authenticated native Docs UI tests with bounded fixture lifetime."""
3
4import argparse
5from datetime import datetime, timezone
6import json
7import os
8from pathlib import Path
9import plistlib
10import signal
11import shutil
12import subprocess
13import tempfile
14import time
15import urllib.request
16
17
18def 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
174if __name__ == "__main__":
175 raise SystemExit(main())
176