admin-frontend.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. #!/usr/bin/env python3
  2. """Compare cold admin page loads with a fixed, local API response.
  3. Requires Python Playwright and its Chromium browser. No real account or backend
  4. is contacted. Run with --baseline DIST/browser [--candidate DIST/browser].
  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. MEMBERS = {
  18. "page": 1,
  19. "pageSize": 20,
  20. "total": 0,
  21. "items": [],
  22. "agents": [],
  23. "summary": {"total": 0, "todayNew": 0, "sevenDayNew": 0, "studying": 0, "followUp": 0},
  24. "refreshedAt": "2026-09-26T00:00:00.000Z",
  25. }
  26. REPORTS = {
  27. "page": 1,
  28. "pageSize": 20,
  29. "total": 0,
  30. "items": [],
  31. "summary": {"reportCount": 0, "studentCount": 0, "learnedCount": 0, "masteredCount": 0, "reviewDueCount": 0, "masteryRate": 0},
  32. "sourceStatus": {"state": "synced", "label": "已同步", "message": "本地模拟数据", "lastSuccessAt": "", "lagSeconds": 0},
  33. }
  34. VOCABULARY_TREE = {"items": [], "total": 0, "totalWords": 123, "refreshedAt": "2026-09-26T00:00:00.000Z"}
  35. VOCABULARY_WORDS = {"page": 1, "pageSize": 30, "total": 7, "items": [], "refreshedAt": "2026-09-26T00:00:00.000Z"}
  36. SESSION = {
  37. "objectId": "perf-test",
  38. "username": "perf-test",
  39. "sessionToken": "local-perf-test",
  40. "displayName": "测速账号",
  41. "roles": [],
  42. "operationsRole": "ops-manager",
  43. "isSuperAdmin": False,
  44. }
  45. class AppHandler(SimpleHTTPRequestHandler):
  46. def do_GET(self):
  47. path = Path(self.directory) / urlsplit(self.path).path.lstrip("/")
  48. if not path.is_file():
  49. self.path = "/index.html"
  50. return super().do_GET()
  51. def log_message(self, *_args):
  52. pass
  53. def serve(directory):
  54. server = ThreadingHTTPServer(("127.0.0.1", 0), partial(AppHandler, directory=str(directory)))
  55. threading.Thread(target=server.serve_forever, daemon=True).start()
  56. return server
  57. def percentile(values, percentage):
  58. return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[percentage - 1], 1)
  59. async def measure(browser, base_url, page_name):
  60. context = await browser.new_context(viewport={"width": 1440, "height": 900})
  61. await context.add_init_script(
  62. "sessionStorage.setItem('xiaoshu_admin_session_v1', " + json.dumps(json.dumps(SESSION)) + ");"
  63. )
  64. page = await context.new_page()
  65. cdp = await context.new_cdp_session(page)
  66. await cdp.send("Network.enable")
  67. await cdp.send("Network.setCacheDisabled", {"cacheDisabled": True})
  68. await cdp.send("Network.emulateNetworkConditions", {
  69. "offline": False, "latency": 20,
  70. "downloadThroughput": 4 * 1024 * 1024 / 8,
  71. "uploadThroughput": 3 * 1024 * 1024 / 8,
  72. })
  73. async def mock_gateway(route):
  74. await asyncio.sleep(0.15)
  75. if page_name == "vocabulary":
  76. operation = (route.request.post_data_json or {}).get("params", {}).get("operation")
  77. payload = VOCABULARY_TREE if operation == "ops/vocabulary/tree" else VOCABULARY_WORDS
  78. else:
  79. payload = REPORTS if page_name == "learning-reports" else MEMBERS
  80. await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "data": payload}))
  81. await page.route("**/cloud-functions/**", mock_gateway)
  82. try:
  83. await page.goto(base_url + "/admin/" + page_name, wait_until="commit")
  84. if page_name == "vocabulary":
  85. await page.get_by_text("完整管理 123 条词汇", exact=False).first.wait_for(timeout=30000)
  86. await page.get_by_text("共 7 条符合条件的词汇", exact=False).first.wait_for(timeout=30000)
  87. else:
  88. ready_text = "当前筛选范围暂无学习报表" if page_name == "learning-reports" else "没有符合条件的会员"
  89. await page.get_by_text(ready_text, exact=True).first.wait_for(timeout=30000)
  90. result = await page.evaluate("""() => {
  91. const js = performance.getEntriesByType('resource').filter(item => item.name.endsWith('.js'));
  92. return {
  93. readyMs: performance.now(),
  94. jsRequests: js.length,
  95. jsTransferBytes: js.reduce((total, item) => total + item.transferSize, 0),
  96. jsNames: js.map(item => new URL(item.name).pathname.split('/').pop())
  97. };
  98. }""")
  99. return result
  100. finally:
  101. await context.close()
  102. async def main():
  103. parser = argparse.ArgumentParser()
  104. parser.add_argument("--baseline", type=Path, required=True)
  105. parser.add_argument("--candidate", type=Path)
  106. parser.add_argument("--page", choices=["members", "learning-reports", "vocabulary"], default="members")
  107. parser.add_argument("--runs", type=int, default=10)
  108. parser.add_argument("--out", type=Path)
  109. parser.add_argument("--max-js-bytes", type=int, help="Fail if the candidate loads more JavaScript bytes")
  110. parser.add_argument("--ceilings", type=Path, help="JSON map from page name to JavaScript byte ceiling")
  111. args = parser.parse_args()
  112. if args.runs < 2:
  113. parser.error("--runs must be at least 2")
  114. roots = {"A": args.baseline}
  115. if args.candidate:
  116. roots["B"] = args.candidate
  117. servers = {name: serve(root) for name, root in roots.items()}
  118. urls = {name: f"http://127.0.0.1:{server.server_port}" for name, server in servers.items()}
  119. runs = {name: [] for name in roots}
  120. try:
  121. async with async_playwright() as playwright:
  122. browser = await playwright.chromium.launch()
  123. try:
  124. for index in range(args.runs):
  125. for name in (list(roots) if index % 2 == 0 else list(reversed(roots))):
  126. runs[name].append(await measure(browser, urls[name], args.page))
  127. finally:
  128. await browser.close()
  129. finally:
  130. for server in servers.values():
  131. server.shutdown()
  132. summary = {
  133. name: {
  134. "n": len(items),
  135. "readyP50Ms": percentile([item["readyMs"] for item in items], 50),
  136. "readyP75Ms": percentile([item["readyMs"] for item in items], 75),
  137. "jsRequests": max(item["jsRequests"] for item in items),
  138. "jsTransferBytes": max(item["jsTransferBytes"] for item in items),
  139. "jsNames": items[-1]["jsNames"],
  140. "runs": items,
  141. }
  142. for name, items in runs.items()
  143. }
  144. print(json.dumps({name: {key: value for key, value in item.items() if key not in ("runs", "jsNames")} for name, item in summary.items()}, ensure_ascii=False, indent=2))
  145. if args.out:
  146. args.out.parent.mkdir(parents=True, exist_ok=True)
  147. args.out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
  148. ceiling = args.max_js_bytes
  149. if args.ceilings:
  150. ceiling = json.loads(args.ceilings.read_text(encoding="utf-8"))[args.page]
  151. if ceiling is not None:
  152. measured = summary["B" if args.candidate else "A"]["jsTransferBytes"]
  153. if measured > ceiling:
  154. print(f"JavaScript transfer ceiling exceeded: {measured} > {ceiling}", file=sys.stderr)
  155. raise SystemExit(1)
  156. if __name__ == "__main__":
  157. asyncio.run(main())