admin-menu-return.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. #!/usr/bin/env python3
  2. """Paired measurement of returning to each already opened operations page.
  3. The gateway is fixed and synthetic. We time the visible heading and count
  4. business data requests before and after return, excluding lightweight version
  5. checks. This is a navigation and redundant-request test, not a live-data test.
  6. """
  7. import argparse
  8. import asyncio
  9. import json
  10. import statistics
  11. import sys
  12. import threading
  13. from collections import Counter
  14. from functools import partial
  15. from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
  16. from pathlib import Path
  17. from urllib.parse import urlsplit
  18. from playwright.async_api import async_playwright
  19. SESSION = {"objectId": "perf-test", "username": "perf-test", "sessionToken": "local-perf-test",
  20. "displayName": "测速账号", "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False}
  21. DASHBOARD = {
  22. "identity": {"objectId": "perf-test", "username": "perf-test", "displayName": "运营经理",
  23. "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False},
  24. "date": "2026-09-26", "metrics": [], "alerts": [], "refreshedAt": "2026-09-26T06:00:00.000Z",
  25. "liveSourceAvailable": True,
  26. "scheduleTrend": {"period": "week", "periodLabel": "近 7 天", "dateFrom": "2026-09-20",
  27. "dateTo": "2026-09-26", "points": [{"key": "2026-09-26", "label": "9月26日",
  28. "shortLabel": "周六", "courseCount": 2, "studentCount": 2,
  29. "completedCount": 1, "isToday": True}], "todayCourseCount": 2,
  30. "todayStudentCount": 2, "totalCourseCount": 2, "totalStudentCount": 2,
  31. "maxValue": 2, "refreshedAt": "2026-09-26T06:00:00.000Z",
  32. "liveSourceAvailable": True},
  33. }
  34. MODULES = {
  35. "dashboard": "运营经理,欢迎回来",
  36. "members": "会员管理",
  37. "stores": "门店管理",
  38. "schedule": "排课中心",
  39. "learning-reports": "学习报表",
  40. "coaches": "陪练老师",
  41. "payroll": "工资结算",
  42. "vocabulary": "词库管理",
  43. "special-training": "专项训练",
  44. }
  45. SELECTORS = {module: ("app-admin-dashboard" if module == "dashboard" else f"app-operations-{module}")
  46. for module in MODULES}
  47. SELECTORS["members"] = "app-operations-users"
  48. class AppHandler(SimpleHTTPRequestHandler):
  49. def do_GET(self):
  50. if not (Path(self.directory) / urlsplit(self.path).path.lstrip("/")).is_file():
  51. self.path = "/index.html"
  52. return super().do_GET()
  53. def log_message(self, *_args):
  54. pass
  55. def serve(directory):
  56. server = ThreadingHTTPServer(("127.0.0.1", 0), partial(AppHandler, directory=str(directory)))
  57. threading.Thread(target=server.serve_forever, daemon=True).start()
  58. return server
  59. def p75(values):
  60. if len(values) == 1:
  61. return values[0]
  62. return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[74], 1)
  63. async def measure(browser, url):
  64. context = await browser.new_context(viewport={"width": 1440, "height": 900})
  65. await context.add_init_script(
  66. "sessionStorage.setItem('xiaoshu_admin_session_v1', " + json.dumps(json.dumps(SESSION)) + ");"
  67. )
  68. page = await context.new_page()
  69. counts = Counter()
  70. errors = []
  71. page.on("pageerror", lambda error: errors.append(str(error)))
  72. async def mock_gateway(route):
  73. operation = (route.request.post_data_json or {}).get("params", {}).get("operation", "")
  74. if operation not in ("meta", "ops/live/versions"):
  75. counts[operation] += 1
  76. await asyncio.sleep(0.12 if operation != "ops/dashboard/summary" else 0.7)
  77. if operation == "meta":
  78. payload = {"identity": DASHBOARD["identity"]}
  79. elif operation == "ops/dashboard/summary":
  80. payload = DASHBOARD
  81. elif operation == "ops/live/versions":
  82. payload = {"version": "fixed-v1", "scopeVersions": {}, "refreshedAt": "2026-09-26T06:00:00.000Z",
  83. "syncStatus": {"state": "current"}}
  84. else:
  85. payload = {"items": [], "total": 0, "page": 1, "pageSize": 20}
  86. await route.fulfill(status=200, content_type="application/json",
  87. body=json.dumps({"success": True, "data": payload}))
  88. await page.route("**/cloud-functions/**", mock_gateway)
  89. try:
  90. await page.goto(url + "/admin/dashboard", wait_until="commit")
  91. await page.get_by_role("heading", name=MODULES["dashboard"]).wait_for(timeout=30000)
  92. await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
  93. results = {}
  94. for module, title in MODULES.items():
  95. target = page.locator(f'#admin-sidebar a[href="/admin/{module}"]').last
  96. first_counts = counts.copy()
  97. if module != "dashboard":
  98. await target.click()
  99. await page.locator(SELECTORS[module]).wait_for(timeout=30000)
  100. await page.wait_for_timeout(220)
  101. target_operations = set((counts - first_counts).keys()) if module != "dashboard" else {"ops/dashboard/summary"}
  102. await page.locator('#admin-sidebar a[href="/admin/dashboard"]').last.click()
  103. await page.locator(SELECTORS["dashboard"]).wait_for(timeout=30000)
  104. if module == "dashboard":
  105. await page.locator('#admin-sidebar a[href="/admin/schedule"]').last.click()
  106. await page.locator(SELECTORS["schedule"]).wait_for(timeout=30000)
  107. before_counts = counts.copy()
  108. start = await page.evaluate("performance.now()")
  109. await target.click()
  110. await page.locator(SELECTORS[module]).wait_for(timeout=30000)
  111. if module == "dashboard":
  112. await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
  113. elapsed = round(await page.evaluate("performance.now()") - start, 1)
  114. await page.wait_for_timeout(220)
  115. results[module] = {"returnMs": elapsed, "repeatBusinessRequests":
  116. sum((counts - before_counts)[operation] for operation in target_operations)}
  117. return {"modules": results, "pageErrors": errors}
  118. finally:
  119. await context.close()
  120. async def main():
  121. parser = argparse.ArgumentParser()
  122. parser.add_argument("--baseline", type=Path, required=True)
  123. parser.add_argument("--candidate", type=Path, required=True)
  124. parser.add_argument("--runs", type=int, default=10)
  125. parser.add_argument("--out", type=Path)
  126. parser.add_argument("--ceilings", type=Path)
  127. args = parser.parse_args()
  128. roots = {"A": args.baseline, "B": args.candidate}
  129. servers = {name: serve(path) for name, path in roots.items()}
  130. results = {name: [] for name in roots}
  131. try:
  132. async with async_playwright() as playwright:
  133. browser = await playwright.chromium.launch()
  134. try:
  135. for index in range(args.runs):
  136. for name in (("A", "B") if index % 2 == 0 else ("B", "A")):
  137. results[name].append(await measure(browser, f"http://127.0.0.1:{servers[name].server_port}"))
  138. finally:
  139. await browser.close()
  140. finally:
  141. for server in servers.values():
  142. server.shutdown()
  143. summary = {module: {name: {
  144. "p75Ms": p75([run["modules"][module]["returnMs"] for run in results[name]]),
  145. "repeatBusinessRequests": max(run["modules"][module]["repeatBusinessRequests"] for run in results[name]),
  146. } for name in roots} for module in MODULES}
  147. print(json.dumps(summary, ensure_ascii=False, indent=2))
  148. failures = [f"{module}: {entry['B']}" for module, entry in summary.items()
  149. if entry["B"]["repeatBusinessRequests"] != 0]
  150. if any(run["pageErrors"] for run in results["B"]):
  151. failures.append("candidate browser page errors")
  152. if args.ceilings:
  153. limits = json.loads(args.ceilings.read_text(encoding="utf-8"))
  154. failures.extend(f"{module}: {summary[module]['B']['p75Ms']} > {maximum} ms"
  155. for module, maximum in limits.items() if summary[module]["B"]["p75Ms"] > maximum)
  156. if args.out:
  157. args.out.parent.mkdir(parents=True, exist_ok=True)
  158. args.out.write_text(json.dumps({"summary": summary, "runs": results}, ensure_ascii=False, indent=2),
  159. encoding="utf-8")
  160. if failures:
  161. print("Return-navigation ceiling exceeded: " + "; ".join(failures), file=sys.stderr)
  162. raise SystemExit(1)
  163. if __name__ == "__main__":
  164. asyncio.run(main())