Files
FreeCADWasmDev/tools/show_web_module_smoke_progress.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

165 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Show progress, throughput, and bottlenecks for the one-shot Web smoke run.
The output follows the observable-state style of show_freecad_web_scheduler.py
and includes its scheduler status anchor for correlation.
"""
from __future__ import annotations
import argparse
import json
import statistics
import time
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = Path(__import__("os").environ.get("FREECAD_SMOKE_RUN_DIR", str(ROOT / "docs/current/module-smoke-once"))).expanduser().resolve() / "manifest.json"
SCHEDULER_STATUS = ROOT / "docs/current/freecad-web-scheduler-status.json"
def parse_time(value: object) -> float | None:
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def scheduler_snapshot() -> dict[str, object]:
"""Expose the same scheduler status anchor used by the scheduler viewer."""
try:
value = json.loads(SCHEDULER_STATUS.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"status": "unknown"}
return {key: value.get(key) for key in ("status", "cycle", "activeTask", "unresolvedFailures", "heartbeatAt")}
def snapshot() -> dict[str, object]:
if not MANIFEST.exists():
return {"status": "not_started", "completed": 0, "total": 0, "message": str(MANIFEST)}
try:
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"status": "unreadable", "message": str(exc)}
results = data.get("results", {}) if isinstance(data.get("results", {}), dict) else {}
total = len(data.get("modules", []))
completed = len(results)
passed = sum(isinstance(v, dict) and v.get("status") == "pass" for v in results.values())
failed = sum(isinstance(v, dict) and v.get("status") != "pass" for v in results.values())
started = parse_time(data.get("startedAt"))
now = parse_time(data.get("completedAt")) or time.time()
elapsed = max(0.0, now - started) if started else 0.0
durations = [float(v["durationSeconds"]) for v in results.values() if isinstance(v, dict) and isinstance(v.get("durationSeconds"), (int, float))]
average = statistics.mean(durations) if durations else 0.0
median = statistics.median(durations) if durations else 0.0
remaining = max(0, total - completed)
current = data.get("current") if isinstance(data.get("current"), dict) else None
current_elapsed = max(0.0, time.time() - float(current["startedEpoch"])) if current and isinstance(current.get("startedEpoch"), (int, float)) else 0.0
completed_per_minute = completed / (elapsed / 60.0) if elapsed > 0 else 0.0
slowest = sorted(
((name, float(v.get("durationSeconds", 0.0)), v.get("status")) for name, v in results.items() if isinstance(v, dict)),
key=lambda item: item[1], reverse=True,
)[:5]
phase_seconds = {
"compile": round(sum(float(v.get("compileDurationSeconds", 0.0)) for v in results.values() if isinstance(v, dict)), 3),
"build": round(sum(float(v.get("buildDurationSeconds", 0.0)) for v in results.values() if isinstance(v, dict)), 3),
"smoke": round(sum(float(v.get("smokeDurationSeconds", v.get("durationSeconds", 0.0))) for v in results.values() if isinstance(v, dict) and v.get("phase") == "smoke"), 3),
}
timeout_count = sum(1 for v in results.values() if isinstance(v, dict) and v.get("timeoutSeconds"))
build_failures = sum(isinstance(v, dict) and v.get("phase") == "build" for v in results.values())
compile_failures = sum(isinstance(v, dict) and v.get("phase") == "compile" for v in results.values())
failure_classes: dict[str, int] = {}
for value in results.values():
if not isinstance(value, dict) or value.get("status") == "pass":
continue
stderr = str(value.get("stderr", ""))
if value.get("timeoutSeconds") or value.get("interrupted"):
category = "timeout_or_interrupted"
elif "require is not defined in ES module scope" in stderr:
category = "esm_cjs_worker"
elif "compiled " in stderr and "path is required" in stderr:
category = "missing_facade_argument"
elif "ERR_MODULE_NOT_FOUND" in stderr:
category = "missing_runtime_artifact"
elif "FROZEN_CACHE" in stderr or "cache" in stderr.lower() and value.get("phase") == "build":
category = "build_cache"
elif value.get("phase") == "build":
category = "build_failure"
else:
category = "runtime_or_assertion"
failure_classes[category] = failure_classes.get(category, 0) + 1
measured_total = sum(duration for _, duration, _ in slowest)
bottleneck = slowest[0] if slowest else None
return {
"status": data.get("status", "unknown"), "completed": completed, "total": total,
"passed": passed, "failed": failed, "remaining": remaining, "elapsedSeconds": round(elapsed, 3),
"completedPerMinute": round(completed_per_minute, 3), "averageDurationSeconds": round(average, 3),
"medianDurationSeconds": round(median, 3), "estimatedRemainingSeconds": round(remaining * median, 3),
"current": current, "currentElapsedSeconds": round(current_elapsed, 3), "slowest": slowest,
"phaseSeconds": phase_seconds, "timeouts": timeout_count, "buildFailures": build_failures, "compileFailures": compile_failures,
"efficiencyVerdict": (
"LOW: timeout dominates smoke time" if timeout_count and slowest else
"MIXED: build/compile failures present" if build_failures or compile_failures else
"GOOD: no timeout or preparation bottleneck"
),
"failureClasses": failure_classes,
"bottleneck": ({"module": bottleneck[0], "seconds": bottleneck[1], "shareOfTop5": round(100.0 * bottleneck[1] / measured_total, 2)} if bottleneck and measured_total else None),
"failedModules": [name for name, v in results.items() if isinstance(v, dict) and v.get("status") != "pass"],
"scheduler": scheduler_snapshot(),
}
def render(view: dict[str, object]) -> str:
if view.get("status") in {"not_started", "unreadable"}:
return json.dumps(view, ensure_ascii=False, indent=2)
total = int(view["total"])
completed = int(view["completed"])
percent = (100.0 * completed / total) if total else 0.0
lines = [
f"status={view['status']} progress={completed}/{total} ({percent:.1f}%) pass={view['passed']} fail={view['failed']} remaining={view['remaining']}",
f"elapsed={view['elapsedSeconds']}s throughput={view['completedPerMinute']}/min average/module={view['averageDurationSeconds']}s median/module={view['medianDurationSeconds']}s est_remaining={view['estimatedRemainingSeconds']}s",
f"phaseSeconds: compile={view['phaseSeconds']['compile']} build={view['phaseSeconds']['build']} smoke={view['phaseSeconds']['smoke']} timeouts={view['timeouts']} buildFailures={view['buildFailures']} compileFailures={view['compileFailures']}",
f"efficiencyVerdict={view['efficiencyVerdict']}",
]
current = view.get("current")
if isinstance(current, dict):
lines.append(f"current={current.get('module')} phase={current.get('phase')} elapsed={view['currentElapsedSeconds']}s timeout={current.get('timeoutSeconds', '-')}")
if view["slowest"]:
lines.append("slowest=" + ", ".join(f"{name}:{duration:.3f}s/{status}" for name, duration, status in view["slowest"]))
if view.get("bottleneck"):
b = view["bottleneck"]
lines.append(f"bottleneck={b['module']} {b['seconds']:.3f}s ({b['shareOfTop5']:.1f}% of top-5 measured time)")
if view.get("failureClasses"):
lines.append("failureClasses=" + ", ".join(f"{name}:{count}" for name, count in sorted(view["failureClasses"].items(), key=lambda item: (-item[1], item[0]))))
if view["failedModules"]:
lines.append("failed=" + ", ".join(view["failedModules"]))
scheduler = view.get("scheduler")
if isinstance(scheduler, dict):
lines.append(
"scheduler(reference=tools/show_freecad_web_scheduler.py): "
f"status={scheduler.get('status', '-')} cycle={scheduler.get('cycle', '-')} "
f"activeTask={scheduler.get('activeTask', '-')} unresolvedFailures={scheduler.get('unresolvedFailures', '-') }"
)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--watch", type=float, metavar="SECONDS", help="refresh continuously")
parser.add_argument("--json", action="store_true", help="emit a JSON snapshot")
args = parser.parse_args()
while True:
view = snapshot()
print(json.dumps(view, ensure_ascii=False) if args.json else render(view), flush=True)
if not args.watch:
return 0
time.sleep(max(0.2, args.watch))
print("\033[2J\033[H", end="")
if __name__ == "__main__":
raise SystemExit(main())