admin-menu-switch.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. #!/usr/bin/env python3
  2. """Measure first sidebar navigation to each operations module after the dashboard settles.
  3. Runs the production bundles against a local, fixed gateway stub. This measures
  4. route-code availability and first page heading; it is not a live API benchmark.
  5. """
  6. import argparse
  7. import asyncio
  8. import json
  9. import statistics
  10. import sys
  11. import threading
  12. from functools import partial
  13. from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
  14. from pathlib import Path
  15. from urllib.parse import urlsplit
  16. from playwright.async_api import async_playwright
  17. SESSION = {
  18. "objectId": "perf-test", "username": "perf-test", "sessionToken": "local-perf-test",
  19. "displayName": "测速账号", "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False,
  20. }
  21. MODULES = {
  22. "stores": "门店管理", "schedule": "排课中心", "coaches": "陪练老师",
  23. "payroll": "工资结算", "vocabulary": "词库管理", "special-training": "专项训练",
  24. }
  25. class AppHandler(SimpleHTTPRequestHandler):
  26. def do_GET(self):
  27. if not (Path(self.directory) / urlsplit(self.path).path.lstrip("/")).is_file():
  28. self.path = "/index.html"
  29. return super().do_GET()
  30. def log_message(self, *_args):
  31. pass
  32. def serve(directory):
  33. server = ThreadingHTTPServer(("127.0.0.1", 0), partial(AppHandler, directory=str(directory)))
  34. threading.Thread(target=server.serve_forever, daemon=True).start()
  35. return server
  36. def p75(values):
  37. return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[74], 1)
  38. async def measure(browser, base_url):
  39. context = await browser.new_context(viewport={"width": 1440, "height": 900})
  40. await context.add_init_script(
  41. "sessionStorage.setItem('xiaoshu_admin_session_v1', " + json.dumps(json.dumps(SESSION)) + ");"
  42. )
  43. page = await context.new_page()
  44. cdp = await context.new_cdp_session(page)
  45. await cdp.send("Network.enable")
  46. await cdp.send("Network.setCacheDisabled", {"cacheDisabled": False})
  47. await cdp.send("Network.emulateNetworkConditions", {
  48. "offline": False, "latency": 20,
  49. "downloadThroughput": 4 * 1024 * 1024 / 8,
  50. "uploadThroughput": 3 * 1024 * 1024 / 8,
  51. })
  52. async def mock_gateway(route):
  53. await asyncio.sleep(0.15)
  54. await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "data": {}}))
  55. await page.route("**/cloud-functions/**", mock_gateway)
  56. try:
  57. await page.goto(base_url + "/admin/dashboard", wait_until="commit")
  58. await page.locator('#admin-sidebar a[href="/admin/stores"]').wait_for(timeout=30000)
  59. await page.wait_for_timeout(4500)
  60. results = {}
  61. for route, title in MODULES.items():
  62. before = await page.evaluate("performance.now()")
  63. await page.locator(f'#admin-sidebar a[href="/admin/{route}"]').click()
  64. await page.get_by_role("heading", name=title, exact=True).first.wait_for(timeout=30000)
  65. after = await page.evaluate("performance.now()")
  66. results[route] = round(after - before, 1)
  67. return results
  68. finally:
  69. await context.close()
  70. async def main():
  71. parser = argparse.ArgumentParser()
  72. parser.add_argument("--baseline", type=Path, required=True)
  73. parser.add_argument("--candidate", type=Path, required=True)
  74. parser.add_argument("--runs", type=int, default=10)
  75. parser.add_argument("--out", type=Path)
  76. parser.add_argument("--ceilings", type=Path, help="JSON map of maximum candidate p75 milliseconds by route")
  77. args = parser.parse_args()
  78. servers = {"A": serve(args.baseline), "B": serve(args.candidate)}
  79. urls = {name: f"http://127.0.0.1:{server.server_port}" for name, server in servers.items()}
  80. runs = {name: {route: [] for route in MODULES} for name in servers}
  81. try:
  82. async with async_playwright() as playwright:
  83. browser = await playwright.chromium.launch()
  84. try:
  85. for index in range(args.runs):
  86. for name in (("A", "B") if index % 2 == 0 else ("B", "A")):
  87. result = await measure(browser, urls[name])
  88. for route, value in result.items():
  89. runs[name][route].append(value)
  90. finally:
  91. await browser.close()
  92. finally:
  93. for server in servers.values():
  94. server.shutdown()
  95. summary = {route: {name: {"p75Ms": p75(runs[name][route]), "runs": runs[name][route]}
  96. for name in servers} for route in MODULES}
  97. print(json.dumps({route: {name: entry["p75Ms"] for name, entry in results.items()}
  98. for route, results in summary.items()}, ensure_ascii=False, indent=2))
  99. if args.out:
  100. args.out.parent.mkdir(parents=True, exist_ok=True)
  101. args.out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
  102. if args.ceilings:
  103. ceilings = json.loads(args.ceilings.read_text(encoding="utf-8"))
  104. failures = [f"{route}: {summary[route]['B']['p75Ms']} > {maximum} ms"
  105. for route, maximum in ceilings.items() if summary[route]["B"]["p75Ms"] > maximum]
  106. if failures:
  107. print("Menu-switch ceiling exceeded: " + "; ".join(failures), file=sys.stderr)
  108. raise SystemExit(1)
  109. if __name__ == "__main__":
  110. asyncio.run(main())