| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- #!/usr/bin/env python3
- """Paired measurement of returning to each already opened operations page.
- The gateway is fixed and synthetic. We time the visible heading and count
- business data requests before and after return, excluding lightweight version
- checks. This is a navigation and redundant-request test, not a live-data test.
- """
- import argparse
- import asyncio
- import json
- import statistics
- import sys
- import threading
- from collections import Counter
- from functools import partial
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
- from pathlib import Path
- from urllib.parse import urlsplit
- from playwright.async_api import async_playwright
- SESSION = {"objectId": "perf-test", "username": "perf-test", "sessionToken": "local-perf-test",
- "displayName": "测速账号", "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False}
- DASHBOARD = {
- "identity": {"objectId": "perf-test", "username": "perf-test", "displayName": "运营经理",
- "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False},
- "date": "2026-09-26", "metrics": [], "alerts": [], "refreshedAt": "2026-09-26T06:00:00.000Z",
- "liveSourceAvailable": True,
- "scheduleTrend": {"period": "week", "periodLabel": "近 7 天", "dateFrom": "2026-09-20",
- "dateTo": "2026-09-26", "points": [{"key": "2026-09-26", "label": "9月26日",
- "shortLabel": "周六", "courseCount": 2, "studentCount": 2,
- "completedCount": 1, "isToday": True}], "todayCourseCount": 2,
- "todayStudentCount": 2, "totalCourseCount": 2, "totalStudentCount": 2,
- "maxValue": 2, "refreshedAt": "2026-09-26T06:00:00.000Z",
- "liveSourceAvailable": True},
- }
- MODULES = {
- "dashboard": "运营经理,欢迎回来",
- "members": "会员管理",
- "stores": "门店管理",
- "schedule": "排课中心",
- "learning-reports": "学习报表",
- "coaches": "陪练老师",
- "payroll": "工资结算",
- "vocabulary": "词库管理",
- "special-training": "专项训练",
- }
- SELECTORS = {module: ("app-admin-dashboard" if module == "dashboard" else f"app-operations-{module}")
- for module in MODULES}
- SELECTORS["members"] = "app-operations-users"
- class AppHandler(SimpleHTTPRequestHandler):
- def do_GET(self):
- if not (Path(self.directory) / urlsplit(self.path).path.lstrip("/")).is_file():
- self.path = "/index.html"
- return super().do_GET()
- def log_message(self, *_args):
- pass
- def serve(directory):
- server = ThreadingHTTPServer(("127.0.0.1", 0), partial(AppHandler, directory=str(directory)))
- threading.Thread(target=server.serve_forever, daemon=True).start()
- return server
- def p75(values):
- if len(values) == 1:
- return values[0]
- return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[74], 1)
- async def measure(browser, url):
- context = await browser.new_context(viewport={"width": 1440, "height": 900})
- await context.add_init_script(
- "sessionStorage.setItem('xiaoshu_admin_session_v1', " + json.dumps(json.dumps(SESSION)) + ");"
- )
- page = await context.new_page()
- counts = Counter()
- errors = []
- page.on("pageerror", lambda error: errors.append(str(error)))
- async def mock_gateway(route):
- operation = (route.request.post_data_json or {}).get("params", {}).get("operation", "")
- if operation not in ("meta", "ops/live/versions"):
- counts[operation] += 1
- await asyncio.sleep(0.12 if operation != "ops/dashboard/summary" else 0.7)
- if operation == "meta":
- payload = {"identity": DASHBOARD["identity"]}
- elif operation == "ops/dashboard/summary":
- payload = DASHBOARD
- elif operation == "ops/live/versions":
- payload = {"version": "fixed-v1", "scopeVersions": {}, "refreshedAt": "2026-09-26T06:00:00.000Z",
- "syncStatus": {"state": "current"}}
- else:
- payload = {"items": [], "total": 0, "page": 1, "pageSize": 20}
- await route.fulfill(status=200, content_type="application/json",
- body=json.dumps({"success": True, "data": payload}))
- await page.route("**/cloud-functions/**", mock_gateway)
- try:
- await page.goto(url + "/admin/dashboard", wait_until="commit")
- await page.get_by_role("heading", name=MODULES["dashboard"]).wait_for(timeout=30000)
- await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
- results = {}
- for module, title in MODULES.items():
- target = page.locator(f'#admin-sidebar a[href="/admin/{module}"]').last
- first_counts = counts.copy()
- if module != "dashboard":
- await target.click()
- await page.locator(SELECTORS[module]).wait_for(timeout=30000)
- await page.wait_for_timeout(220)
- target_operations = set((counts - first_counts).keys()) if module != "dashboard" else {"ops/dashboard/summary"}
- await page.locator('#admin-sidebar a[href="/admin/dashboard"]').last.click()
- await page.locator(SELECTORS["dashboard"]).wait_for(timeout=30000)
- if module == "dashboard":
- await page.locator('#admin-sidebar a[href="/admin/schedule"]').last.click()
- await page.locator(SELECTORS["schedule"]).wait_for(timeout=30000)
- before_counts = counts.copy()
- start = await page.evaluate("performance.now()")
- await target.click()
- await page.locator(SELECTORS[module]).wait_for(timeout=30000)
- if module == "dashboard":
- await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
- elapsed = round(await page.evaluate("performance.now()") - start, 1)
- await page.wait_for_timeout(220)
- results[module] = {"returnMs": elapsed, "repeatBusinessRequests":
- sum((counts - before_counts)[operation] for operation in target_operations)}
- return {"modules": results, "pageErrors": errors}
- finally:
- await context.close()
- async def main():
- parser = argparse.ArgumentParser()
- parser.add_argument("--baseline", type=Path, required=True)
- parser.add_argument("--candidate", type=Path, required=True)
- parser.add_argument("--runs", type=int, default=10)
- parser.add_argument("--out", type=Path)
- parser.add_argument("--ceilings", type=Path)
- args = parser.parse_args()
- roots = {"A": args.baseline, "B": args.candidate}
- servers = {name: serve(path) for name, path in roots.items()}
- results = {name: [] for name in roots}
- try:
- async with async_playwright() as playwright:
- browser = await playwright.chromium.launch()
- try:
- for index in range(args.runs):
- for name in (("A", "B") if index % 2 == 0 else ("B", "A")):
- results[name].append(await measure(browser, f"http://127.0.0.1:{servers[name].server_port}"))
- finally:
- await browser.close()
- finally:
- for server in servers.values():
- server.shutdown()
- summary = {module: {name: {
- "p75Ms": p75([run["modules"][module]["returnMs"] for run in results[name]]),
- "repeatBusinessRequests": max(run["modules"][module]["repeatBusinessRequests"] for run in results[name]),
- } for name in roots} for module in MODULES}
- print(json.dumps(summary, ensure_ascii=False, indent=2))
- failures = [f"{module}: {entry['B']}" for module, entry in summary.items()
- if entry["B"]["repeatBusinessRequests"] != 0]
- if any(run["pageErrors"] for run in results["B"]):
- failures.append("candidate browser page errors")
- if args.ceilings:
- limits = json.loads(args.ceilings.read_text(encoding="utf-8"))
- failures.extend(f"{module}: {summary[module]['B']['p75Ms']} > {maximum} ms"
- for module, maximum in limits.items() if summary[module]["B"]["p75Ms"] > maximum)
- if args.out:
- args.out.parent.mkdir(parents=True, exist_ok=True)
- args.out.write_text(json.dumps({"summary": summary, "runs": results}, ensure_ascii=False, indent=2),
- encoding="utf-8")
- if failures:
- print("Return-navigation ceiling exceeded: " + "; ".join(failures), file=sys.stderr)
- raise SystemExit(1)
- if __name__ == "__main__":
- asyncio.run(main())
|