Files
MichaelScottsman--LiquidAss…/lab/lab_p2cmd.py
T

1100 lines
50 KiB
Python

#!/usr/bin/env python3
"""Phase 2 lab commands, device side (P10, docs/phase2/contracts/lab.md).
Imported lazily by lab.py; runs on the Frame next to it. Files a command wants
on the PC are announced on stdout as one line each:
@@file <remote path> <local path relative to glass-shell/> fetch, then delete remotely
@@hv <remote path> <name> headset-view frame: glass.py
measures it and deletes both copies
"""
import json
import os
import shlex
import sys
import time
import lab
from lab import Lock, flock_wait, lab_js, opt, flag, STEP, steam_build, run_pre
NATIVE_LOCK = "/tmp/lgs/native.lock"
SHOTS_REMOTE = "/tmp/lgs/shots" # runtime state only under /tmp/lgs (hard rule 7, review R1 m9)
def stamp():
"""Evidence label of a result: Steam build, date and the native layer's state at the time (review R1 m2: a
CSS-tier step can run while another agent's native-session has native mode on)."""
return {"build": steam_build(), "date": time.strftime("%Y-%m-%dT%H:%M:%S"), "native": lab.native_on()}
class vr_lock:
"""lab-vr.lock for a short read inside a lab.lock step (lock order lab.lock -> lab-vr.lock, as Lock(both=True))."""
def __enter__(self):
self.f = flock_wait(lab.LOCK.replace("lab.lock", "lab-vr.lock"), 240, "lab-vr.lock")
return self
def __exit__(self, *a):
import fcntl
try:
fcntl.flock(self.f, fcntl.LOCK_UN)
finally:
self.f.close()
def remote_shot_path(name):
os.makedirs(SHOTS_REMOTE, exist_ok=True)
return f"{SHOTS_REMOTE}/{os.path.basename(name)}-{os.getpid()}-{int(time.time() * 1000) % 100000}.png"
def announce_file(remote, local):
print(f"@@file {remote} {local}", flush=True)
# ---------------------------------------------------------------- native-session
def shell_status():
import lgs_shell
try:
return lgs_shell.status()
except Exception as e: # noqa: BLE001
return {"error": str(e)}
def native_state(st):
"""(enabled, glassd ok, reason) from lgs_shell.status()."""
shell = st.get("shell") if isinstance(st.get("shell"), dict) else st
nat = (shell or {}).get("native") or {}
g = (shell or {}).get("glassd") or {}
enabled = bool(nat.get("enabled"))
gok = bool(g.get("running")) and g.get("healthy") is not False and bool(g.get("ready", True))
return enabled, gok, nat.get("reason") or (shell or {}).get("mode") or st.get("error")
def main_html_native():
try:
return lab_js("L.surface('main').document.documentElement.classList.contains('lgs-native')")
except Exception: # noqa: BLE001
return None
def set_step(base=None):
"""Reset the module's step options (to a native session's own options, when given)."""
base = base or {}
STEP["flags"].clear()
STEP["flags"].update(base.get("flags") or {})
STEP["mode"] = base.get("mode")
STEP["media"].clear()
STEP["media"].extend(base.get("media") or [])
STEP["stock"] = bool(base.get("stock"))
STEP["hover"] = base.get("hover")
def run_step(argv, base=None):
"""One nested lab command, in this process: the session's step options (base), then its own on top."""
set_step(base)
argv = list(argv)
if not argv:
return 0
if argv[0] == "shot" and len(argv) >= 3 and "/" not in argv[2]:
name = argv[2]
remote = remote_shot_path(name)
argv[2] = remote
code = _call(argv)
if os.path.exists(remote):
announce_file(remote, f"shots/{name}.png")
return code
if argv[0] == "hv":
# The step's own --flags/--mode/--media/--hover/--stock go on top of the session's, as for every other step
# (lab.main parses them there). Until session 5 this path skipped parse_step(): an hv step's own
# `--mode laser` was dropped, and the look was taken in the session's mode (P7 recheck 12:46).
args = argv[1:]
lab.parse_step(args)
return hv_grab(args)
return _call(argv)
def _call(argv):
try:
lab.main(["lab.py"] + argv)
return 0
except SystemExit as e:
if e.code not in (0, None):
print(f"step {argv[0]} exited: {e.code}", file=sys.stderr)
return e.code if isinstance(e.code, int) else 1
def native_session(args):
pre = opt(args, "--pre")
settle = float(opt(args, "--settle", 2.0))
wait = float(opt(args, "--wait", 1800))
ready_s = float(opt(args, "--ready", 60))
steps = []
while "--step" in args:
steps.append(shlex.split(opt(args, "--step")))
if "--" in args:
i = args.index("--")
steps.append(args[i + 1:])
del args[i:]
import lgs
import lgs_shell
# Step options given to native-session itself apply to every step (each step's own go on top).
sess = {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"],
"hover": STEP.get("hover")}
set_step()
t0 = time.time()
f = flock_wait(NATIVE_LOCK, wait, "native.lock") # order: native.lock -> lab.lock -> lab-vr.lock
out = {"waitedS": round(time.time() - t0, 1), **stamp(), "steps": []}
code = 0
started = False
try:
# Turning the native layer on changes Steam's windows and systemui: hold both lab locks for it.
with Lock(both=True, keep=True):
if not lgs.is_on():
lgs.op("on", quiet=True)
started = True
res = lgs_shell.start(native="on", stay=True)
out["start"] = res
deadline = time.time() + ready_s
enabled = gok = False
reason = None
while time.time() < deadline:
enabled, gok, reason = native_state(shell_status())
if enabled and gok:
break
time.sleep(0.5)
out["native"] = {"enabled": enabled, "glassd": gok, "reason": reason,
"readyS": round(time.time() - (deadline - ready_s), 1)}
if not (enabled and gok):
print(json.dumps(out, indent=1))
print(f"BLOCKED: native layer did not come up in {ready_s:g} s ({reason})")
code = 3
return code
time.sleep(settle)
out["lgsNativeOnMain"] = main_html_native()
print(json.dumps(out, indent=1), flush=True)
if pre:
set_step(sess)
with Lock():
print("pre:", lab_js(pre), flush=True)
for s in steps:
print(f"== step: {' '.join(shlex.quote(a) for a in s)}", flush=True)
c = run_step(s, base=sess)
code = code or (c if c in (1, 3) else 0)
finally:
# Always back to CSS only (lgs on --css), then check it really is; under both lab locks when they
# can be had, and without them rather than leave native mode on.
set_step()
back = None
def to_css():
try:
lgs.op("on", quiet=True, vr=True, native=False)
except Exception as e: # noqa: BLE001
print(f"native-session: lgs on --css failed: {e}", file=sys.stderr)
for _ in range(20):
enabled, _, _ = native_state(shell_status())
if not enabled and not main_html_native():
return True
time.sleep(0.5)
return None
if not started:
# the lab locks were never had (busy for 240 s): native mode was not touched, nothing to give back
print("native-session: native mode was not turned on (the lab locks were busy); nothing to undo", flush=True)
else:
try:
with Lock(both=True, keep=True):
back = to_css()
except SystemExit:
back = to_css()
print(f"native-session: back to CSS only: {'yes' if back else 'NOT CONFIRMED'}", flush=True)
n = purge_own_hv()
if n:
print(f"native-session: deleted {n} unfetched headset-view frame(s) on the Frame", flush=True)
f.close()
return code
# ---------------------------------------------------------------- hv (device half)
HVGRAB = os.path.join(os.path.dirname(lab.HERE), "native", "spike", "hvgrab")
def hv_build():
if os.path.exists(HVGRAB):
return None
d = os.path.dirname(HVGRAB)
cmd = (f"cd {shlex.quote(d)} && g++ -O2 -std=c++17 -I$HOME/frametop/screens/build/include -I. hvgrab.cpp -o hvgrab "
"-L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lvulkan 2>&1")
r = os.popen(cmd).read()
return None if os.path.exists(HVGRAB) else r[-1500:]
HV_REAP_S = 90
def hv_reaper(path):
"""The room frame is deleted on the Frame HV_REAP_S s after capture even if no PC fetches it (glass.py killed
mid-session): a detached `sleep; rm -f` that ends with it (nothing survives a reboot; /tmp is RAM). glass.py
fetches and deletes it within seconds as the @@hv line streams; lab.py also purges stale frames at every lock
entry (review R1 M5)."""
import subprocess
try:
subprocess.Popen(["sh", "-c", f"sleep {HV_REAP_S}; rm -f {shlex.quote(path)}"], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
except OSError as e:
print(f"hv: could not start the frame reaper: {e}", file=sys.stderr)
def purge_own_hv():
"""This process's headset-view frames (native-session's finally)."""
pre = f"hv-{os.getpid()}-"
n = 0
try:
for nm in os.listdir("/tmp/lgs"):
if nm.startswith(pre) and nm.endswith(".png"):
try:
os.remove(os.path.join("/tmp/lgs", nm))
n += 1
except OSError:
pass
except OSError:
pass
return n
HV_GAP_S = 0.5 # between the warm-up grab and the measured one (REQ P7->P10)
def hvgrab_once(out, scale):
"""One hvgrab run into `out`; returns its output (stdout + stderr, stripped)."""
return os.popen(f"env -u LD_PRELOAD {shlex.quote(HVGRAB)} {out} {scale} 2>&1").read().strip()
def hv_grab(args):
"""Capture system.HeadsetView; announce it for glass.py (which measures and deletes it). The frame shows the
room: never kept.
SteamVR refreshes system.HeadsetView only while someone samples it, so the first grab after a pause can return a
picture minutes old (REQ P7->P10, SG-2 rerun 09:15). Every hv therefore grabs `--grabs N` times (default 2),
`--gap S` apart (default 0.5 s), deletes each earlier frame at once (it never leaves the Frame) and measures the
last one only.
`--route R`, `--pre JS|@hover SEL[,MS]` (with `--surface S` for the pre, default main) and `--settle S` open a
lab layer (a menu, an alert) for the capture (REQ C1c->P10): the step then holds lab.lock and lab-vr.lock (in that
order), runs the route and the pre, waits the settle, grabs, and only then gives the usual restore at the lock
exit (menus the step opened are closed, the pointer goes to (1400, 900)). Steam step options apply to it, and any
of them (`--mode`, `--flags`, `--media`, `--stock`) also makes hv such a Steam step (session 5: a plain hv held
only lab-vr.lock, where they are not applied). The settle defaults to 1.5 s in native mode, 0.8 s otherwise."""
offaxis = opt(args, "--offaxis")
rect = opt(args, "--rect")
look = flag(args, "--look")
scale = 1 if flag(args, "--full") else 2 # --full: full-resolution HeadsetView (REQ P9->P10)
route = opt(args, "--route")
pre = opt(args, "--pre")
psurf = opt(args, "--surface", "main")
grabs = max(1, int(opt(args, "--grabs", 2)))
gap = max(0.0, float(opt(args, "--gap", HV_GAP_S)))
# Steam's state for the look: a route, a pre, a hover, or any Steam step option (--mode, --flags, --media,
# --stock). Each makes hv a Steam step (both lab locks; the options applied inside it), so `hv NAME --mode
# laser` alone really is a laser look: P6 reports the laser-only frame menu and P7 builds its copy only in laser
# mode. Until session 5 a plain hv held only lab-vr.lock, where Steam's options are not applied (vr: steps), so
# its --mode was silently dropped (P7 recheck 12:46).
opts_on = bool(STEP.get("mode") or STEP.get("flags") or STEP.get("media") or STEP.get("stock"))
layer = bool(route or pre or STEP.get("hover") or opts_on)
# the scene graph follows a Steam change through P6's report and the daemon's push: 1.5 s in native mode (as
# sgcheck's settle, springs at rest), 0.8 s for the CSS look
settle = float(opt(args, "--settle", (1.5 if lab.native_on() == "on" else 0.8) if layer else 0))
name = args[0] if args and not args[0].startswith("--") else "hv"
err = hv_build()
if err:
print(f"BLOCKED: hvgrab does not build: {err}")
return 3
out = f"/tmp/lgs/hv-{os.getpid()}-{int(time.time() * 1000) % 100000}.png"
info = {"grabs": grabs, "gapS": gap}
# A lab layer kept open for the capture is a Steam step: both lab locks (lab.lock -> lab-vr.lock), Steam's step
# options, and the usual restore at the exit. A plain hv only reads systemui: lab-vr.lock.
with (Lock(both=True) if layer else Lock(surface="vr:systemui")):
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre or STEP.get("hover"):
info["preResult"] = run_pre(pre, psurf)
if settle:
time.sleep(settle)
yawed = None
if offaxis:
# P7's lab hook (contracts/sg.md, lgs_sg.js test.yaw): turn Steam's window about its vertical axis,
# restored below (and by its own 20 s TTL and the watchdog).
try:
yawed = lab_js(f"(window.__LGS_SG && window.__LGS_SG.test && typeof window.__LGS_SG.test.yaw === 'function') ? "
f"window.__LGS_SG.test.yaw({float(offaxis)}, 15000) : null", surface="vr:systemui")
except SystemExit as e:
yawed = {"error": str(e)}
if not yawed or yawed.get("error"):
print(f"BLOCKED: no off-axis hook in vr:systemui ({(yawed or {}).get('error', '__LGS_SG not installed: run hv as a native-session step')})")
return 3
time.sleep(0.6)
r = ""
try:
for i in range(grabs):
if os.path.exists(out):
os.remove(out) # an earlier (warm-up) frame: room imagery, never fetched
if i:
time.sleep(gap)
r = hvgrab_once(out, scale)
finally:
if yawed:
try:
lab_js("window.__LGS_SG.test.yaw(0)", surface="vr:systemui")
except Exception: # noqa: BLE001 - the hook's TTL restores it anyway
pass
if not os.path.exists(out):
print(f"BLOCKED: hvgrab produced no frame ({r[-300:]})")
return 3
hv_reaper(out)
hv_opts = {"look": look, "full": scale == 1, "grabs": grabs, "layer": layer, "mode": STEP.get("mode"),
"native": lab.native_on()}
if rect:
hv_opts["rect"] = [int(float(v)) for v in rect.split(",")]
print(f"@@hv {out} {name} {json.dumps(hv_opts, separators=(',', ':'))}", flush=True)
if info.get("preResult") is not None:
print(f"hv: pre -> {json.dumps(info['preResult'])[:200]}", file=sys.stderr, flush=True)
return 0
# ---------------------------------------------------------------- gates
def load_exemptions():
p = os.path.join(lab.HERE, "exemptions.json")
try:
with open(p, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
def surf_js(surface):
"""lab_js keyword for a surface: vr: pages evaluate inside the page."""
return surface if surface.startswith("vr:") else None
def capture_remote(surface, name):
import asyncio
remote = remote_shot_path(name)
asyncio.run(lab.capture(surface, remote, 0.3))
return remote
def gates(args):
route = opt(args, "--route")
pre = opt(args, "--pre")
only = set((opt(args, "--only") or "aud,size,type,outline,motion").split(","))
keep = opt(args, "--shot")
flag(args, "--json") # glass.py prints JSON either way
rest_ms = float(opt(args, "--rest", 1.0))
theme = opt(args, "--theme", "keep")
stock_route = opt(args, "--stock-route")
surface = args[0]
sj = surf_js(surface)
S = json.dumps(surface)
if theme == "off" or STEP.get("stock"):
only.discard("aud") # stock vs stock: nothing to diff
res = {"surface": surface, "route": route, "pre": bool(pre), "theme": theme, "keep": keep, "stockRoute": stock_route,
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"]},
**stamp(), "gates": {}}
g = res["gates"]
with Lock(surface=surface):
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
restore_on = theme == "off" and not surface.startswith("vr:") and __import__("lgs").is_on()
try:
_gates_body(surface, sj, S, route, pre, only, keep, rest_ms, theme, res, g)
finally:
if restore_on:
lab.set_theme("on", surface)
print("@@gates " + json.dumps(res), flush=True)
return 0
def _gates_body(surface, sj, S, route, pre, only, keep, rest_ms, theme, res, g):
if True:
if theme in ("on", "off"):
lab.set_theme(theme, surface)
time.sleep(0.4)
lab_js(f"(L.gates.exemptions({json.dumps(load_exemptions())}), 1)", surface=sj)
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
t0 = time.time()
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, surface)
t0 = time.time()
if "motion" in only:
early = lab_js(f"L.gates.motionAudit(L.gates.animsNow({S}))", surface=sj)
time.sleep(max(0.0, rest_ms - (time.time() - t0)))
rest = lab_js(f"L.gates.atRest({S})", surface=sj)
css = lab_js(f"L.gates.cssAudit({S})", surface=sj)
g["MOTION"] = {"pass": not early and not rest["running"] and not rest["lgsLeft"] and not rest["infinite"] and not css,
"nonToken": early, "atRest": rest, "cssNonToken": css}
else:
time.sleep(0.3)
if "size" in only:
g["SIZE"] = lab_js(f"L.gates.size({S})", surface=sj)
if "type" in only:
g["TYPE"] = lab_js(f"L.gates.type({S})", surface=sj)
if "outline" in only:
o = lab_js(f"L.gates.outline({S})", surface=sj)
o["dpr"] = lab_js(f"L.surface({S}).devicePixelRatio", surface=sj)
remote = capture_remote(surface, keep or f"_gates_{os.getpid()}")
o["shotRemote"] = remote
# the PC measures exactly this file (not the newest _gates_tmp_* of any process: review R1 m3)
o["shotLocal"] = f"shots/{keep}.png" if keep else f"shots/_gates_tmp_{os.getpid()}_{int(time.time() * 1000) % 1000000}.png"
g["OUTLINE"] = o
announce_file(remote, o["shotLocal"])
if "aud" in only:
stock_route = res.get("stockRoute")
# How many controls the surface shows: a theme switch can close a popup the pre opened (or a runtime
# patch can unmount what it drew), and an AUD over an empty snapshot compares nothing (REQ C2b-R2->P10:
# "controls 0, texts 0" on the "+" popup with wp.c2b). The pre is run again when the surface emptied.
count = f"(() => {{ try {{ return L.gates.controls({S}).length; }} catch (e) {{ return 0; }} }})()"
n0 = lab_js(count, surface=sj)
reran = []
lab.set_theme("off", surface)
time.sleep(0.4)
if stock_route: # REQ C2a->P10 #4: our route themed vs another route stock
lab_js(f"L.nav({json.dumps(stock_route)})")
time.sleep(1.2)
if pre and not stock_route and n0 and not lab_js(count, surface=sj):
run_pre(pre, surface)
time.sleep(0.6)
reran.append("stock")
lab_js(f"(window.__LGS_AUDIT = L.snap({S}), 1)", surface=sj)
lab.set_theme("on", surface)
time.sleep(0.8)
if stock_route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre:
run_pre(pre, surface)
time.sleep(0.5)
elif pre and n0 and not lab_js(count, surface=sj):
run_pre(pre, surface)
time.sleep(0.6)
reran.append("themed")
g["AUD"] = lab_js(f"L.gates.audDiff(window.__LGS_AUDIT, L.snap({S}))", surface=sj)
g["AUD"].pop("moved", None)
if reran:
g["AUD"]["preRerun"] = reran
# No data never reads as a pass (review R1 M3): a snapshot without a single control or text run is an
# error, not an AUD with 0 issues
a_n, b_n = g["AUD"].get("stockRecords", 1), g["AUD"].get("themedRecords", 1)
if not a_n or not b_n:
g["AUD"]["pass"] = False
g["AUD"]["error"] = (f"vacuous: the {'stock' if not a_n else 'themed'} snapshot of {surface} holds no "
"control or text (the surface closed or was empty when the theme was switched)")
g["AUD"].setdefault("issues", []).append("VACUOUS " + g["AUD"]["error"])
# ---------------------------------------------------------------- pad-bfs
def pad_bfs(args):
route = opt(args, "--route")
pre = opt(args, "--pre")
start = opt(args, "--start")
mx = int(opt(args, "--max", 120))
budget = float(opt(args, "--budget", 150))
no_b = flag(args, "--no-b")
flag(args, "--json")
res = {"route": route, **stamp(), "step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "stock": STEP["stock"]}}
with Lock():
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.5)
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, "main") # JS, '@hover SEL[,MS]', then the step's --hover
time.sleep(0.6)
init = lab_js(f"L.bfs.init({json.dumps({'start': start, 'max': mx})})", timeout=30)
if init.get("error"):
print(f"BLOCKED: {init['error']}")
return 3
res["init"] = init
t0 = time.time()
while True:
left = budget - (time.time() - t0)
if left <= 1:
break
st = lab_js(f"L.bfs.step({int(min(40, left) * 1000)})", timeout=75)
print(f"bfs: {st}", file=sys.stderr, flush=True)
if st.get("done"):
break
out = lab_js(f"L.bfs.finish({json.dumps({'noB': no_b})})", timeout=40)
res.update(out)
if route:
lab_js(f"L.nav({json.dumps(route)})")
print("@@bfs " + json.dumps(res), flush=True)
return 0
# ---------------------------------------------------------------- focus (live luma pairs)
RECTS_JS = r"""
(() => {
const w = L.surface(%(surf)s), dpr = w.devicePixelRatio || 1, out = {};
for (const s of %(sels)s) {
let el = null;
try { el = L.q(%(surf)s, s); } catch (e) { out[s] = { error: e.message }; continue; }
if (!el) { out[s] = { error: 'nothing matches' }; continue; }
const r = el.getBoundingClientRect();
out[s] = { rect: [r.x * dpr, r.y * dpr, r.width * dpr, r.height * dpr], radius: (parseFloat(w.getComputedStyle(el).borderTopLeftRadius) || 0) * dpr };
}
return { dpr, rects: out };
})()
"""
def focus_live(args):
"""Captures for `glass.py focus SURF --pairs ...`: one capture per distinct state (the JS run before it),
with the rects of every selector the pairs measure in that state (shot px). The PC computes the luma."""
route = opt(args, "--route")
pre = opt(args, "--pre")
pairs = json.loads(opt(args, "--pairs-json") or opt(args, "--pairs") or "[]") # --pairs: inline JSON (native-session steps)
settle = float(opt(args, "--settle", 0.8))
keep = opt(args, "--keep") # a name prefix: captures kept as shots/<keep>_<n>.png
room = opt(args, "--room") # read on the PC (focus_finish): what translucent texels are seen over
surface = args[0]
sj = surf_js(surface)
states = []
for p in pairs:
for side in ("a", "b"):
st = (p.get(side) or {}).get("state") or ""
if st not in states:
states.append(st)
res = {"surface": surface, "route": route, **stamp(),
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"]},
"states": [], "pairsIn": pairs, "keep": keep, "room": room}
with Lock(surface=surface):
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
try:
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, surface)
time.sleep(0.5)
for i, st in enumerate(states):
sels = sorted({(p.get(side) or {}).get("sel") for p in pairs for side in ("a", "b")
if ((p.get(side) or {}).get("state") or "") == st and (p.get(side) or {}).get("sel")})
out = {"state": st}
if st:
try:
# '@hover SEL[,MS]': a real CDP hover (laser look); '@unhover': pointer away; else JS
if st.startswith("@unhover"):
lab.cdp_unhover()
out["result"] = "unhovered"
else:
out["result"] = run_pre(st, surface) if st.startswith("@hover ") else lab_js(st, surface=sj)
except Exception as e: # noqa: BLE001
out["error"] = str(e)
time.sleep(settle)
r = lab_js(RECTS_JS % {"surf": json.dumps(surface), "sels": json.dumps(sels)}, surface=sj)
out.update(r)
name = f"{keep}_{i}" if keep else f"_focus_{os.getpid()}_{i}"
remote = capture_remote(surface, name)
out["file"] = f"shots/{name}.png"
announce_file(remote, out["file"])
res["states"].append(out)
finally:
try:
lab_js("L.unhover()", surface=sj) # IM 9: pointer to (1400, 900) after a synthetic hover
except Exception: # noqa: BLE001
pass
if route:
try:
lab_js(f"L.nav({json.dumps(route)})")
except Exception: # noqa: BLE001
pass
print("@@focus " + json.dumps(res), flush=True)
return 0
# ---------------------------------------------------------------- motion (filmstrips)
def capture_now(surface, remote):
"""Capture the surface as it is (no settle: the strip's animations are paused at the seeked time)."""
import asyncio
import base64
import lgs
async def go():
t = lab.target_for(surface)
async with lgs.Session(t["webSocketDebuggerUrl"]) as s:
await s.send("Page.bringToFront")
r = await s.send("Page.captureScreenshot", {"format": "png"}, 30)
with open(remote, "wb") as f:
f.write(base64.b64decode(r["data"]))
asyncio.run(go())
MOTION_PROBE = "@@probe" # --selftest: the two-box probe as the pre (review R1 M1)
def motion(args):
route = opt(args, "--route")
pre = opt(args, "--pre")
name = opt(args, "--name") or "motion"
at = [float(x) for x in (opt(args, "--at") or "0,.15,.35,.5,.75,1").split(",")]
rest_s = float(opt(args, "--rest", 1.0))
selftest = opt(args, "--selftest") # MS: the probe's duration
flag(args, "--json")
surface = args[0] if args else "main"
if selftest:
pre, name = MOTION_PROBE, name if name != "motion" else f"_p10_selftest_{int(float(selftest))}"
if not pre:
print("usage: lab.py motion SURF --pre JS [--route R] [--name ID_INTERACTION] [--at 0,.15,...]")
return 2
sj = surf_js(surface)
S = json.dumps(surface)
res = {"surface": surface, "route": route, "name": name, "at": at, **stamp(),
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"]},
"frames": [], "freeze": "pause+seek+2raf"}
if selftest:
res["selftest"] = {"ms": float(selftest)}
with Lock(surface=surface):
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
try:
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
# Let anything already running settle, so the strip holds only what the pre starts.
time.sleep(0.3)
res["before"] = lab_js(f"L.gates.atRest({S})", surface=sj)
# The warm-up capture brings the surface to the front. A popup the pre opens (`motion barpopup`: the
# "+" popup) is not shown yet: its window renders no frame and its capture waits until the CDP timeout
# (REQ C2b-R2->P10, session 5). Then the warm-up is taken after the pre, with its animations paused.
shown = lab_js(f"(() => {{ try {{ return L.surface({S}).document.visibilityState; }} "
f"catch (e) {{ return 'none'; }} }})()", surface=sj)
warm_after = shown != "visible"
res["warm"] = "after the pre (the surface was not shown before it)" if warm_after else "before the pre"
if not warm_after:
os.remove(capture_remote(surface, f"_warm_{os.getpid()}"))
lab_js(f"L.motion.mark({S})", surface=sj) # what runs now is not part of the strip
if pre == MOTION_PROBE:
res["selftest"]["boxes"] = lab_js(f"(() => {{ const r = L.motion.probe({S}, {float(selftest)}); "
f"r.frozen = L.motion.freeze({S}); return r; }})()", surface=sj)
res["frozen"] = res["selftest"]["boxes"].get("frozen")
elif pre.startswith("@hover "):
res["preResult"] = run_pre(pre, surface) # a real hover-in (CDP), then pause what it started
res["frozen"] = lab_js(f"L.motion.freeze({S})", surface=sj)
else:
# The pre and the pause in one evaluation: getAnimations() flushes style, so the CSS animations
# and transitions the pre starts exist and are paused before a frame runs.
r = lab_js(f"(async () => {{ const v = await ({pre}); const n = L.motion.freeze({S}); return [v, n]; }})()",
surface=sj)
res["preResult"], res["frozen"] = (r if isinstance(r, list) and len(r) == 2 else [r, None])
if STEP.get("hover"):
run_pre(None, surface)
lab_js(f"L.motion.freeze({S})", surface=sj)
if warm_after:
try:
os.remove(capture_remote(surface, f"_warm_{os.getpid()}"))
except Exception as e: # noqa: BLE001 - the frames below say whether the surface renders
res["warmError"] = str(e)[:200]
anims = lab_js(f"L.motion.list({S})", surface=sj)
res["animations"] = anims
res["nonToken"] = lab_js(f"L.gates.motionAudit(L.motion.list({S}))", surface=sj)
res["motionLib"] = lab_js(f"L.motion.moAudit({S})", surface=sj)
for f in at:
sk = lab_js(f"L.motion.seek({S}, {f})", surface=sj) # pause, set the time, two rAFs
fname = f"p2_motion_{name}_{f:g}"
remote = remote_shot_path(fname)
capture_now(surface, remote)
announce_file(remote, f"shots/{fname}.png")
res["frames"].append({"f": f, "file": f"shots/{fname}.png", "seeked": sk["seeked"], "rects": sk["rects"],
"raf": sk.get("raf"), "times": sk.get("times")})
finally:
try:
res["released"] = lab_js("L.motion.release()", surface=sj) # play again from the last f
except Exception as e: # noqa: BLE001
res["releaseError"] = str(e)
if pre == MOTION_PROBE:
try:
lab_js(f"L.motion.probeRemove({S})", surface=sj)
except Exception: # noqa: BLE001
pass
# P-52 / PLAN 1.5: nothing left 1 s after the interaction
time.sleep(rest_s)
res["atRest"] = lab_js(f"L.gates.atRest({S})", surface=sj)
res["dpr"] = lab_js(f"L.surface({S}).devicePixelRatio", surface=sj)
res["width"] = lab_js(f"L.surface({S}).innerWidth", surface=sj)
res["m"] = lab_js(f"L.gates.mOf({S})", surface=sj) # D2 2.5 multiplier: P-53's 600 px is 600 x m here
if route:
lab_js(f"L.nav({json.dumps(route)})")
print("@@motion " + json.dumps(res), flush=True)
return 0
# ---------------------------------------------------------------- sgcheck (live data for the depth model)
SG_DOM_JS = r"""
(() => {
const out = {};
const ex = %(extra)s;
for (const name of %(names)s) {
let w;
try { w = L.surface(name); } catch (e) { out[name] = { error: e.message }; continue; }
const d = w.document, R = (el) => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; };
const vis = (el) => L.visible(w, el);
const boxes = (sel) => { let els = []; try { els = [...d.querySelectorAll(L.sel(sel))]; } catch (_) { /* unresolved token */ } return els.filter(vis).map(R); };
const fb = [];
for (const el of [...d.querySelectorAll('[data-lgs-nopop]')].filter(vis)) fb.push(Object.assign(R(el), { name: '[data-lgs-nopop] ' + (L.readable(el).slice(0, 2).join(' ') || el.tagName) }));
for (const f of (ex[name] || [])) for (const b of boxes(f.sel)) fb.push(Object.assign(b, { name: f.name || f.sel }));
out[name] = {
cssW: w.innerWidth, cssH: w.innerHeight, dpr: w.devicePixelRatio,
focusables: L.focusables(name).map((f) => ({ x: f.rect[0], y: f.rect[1], w: f.rect[2], h: f.rect[3] })),
forbidden: fb,
media: boxes('video, [data-lgs-media]'),
destructive: boxes('[data-lgs-destructive]'),
};
}
return out;
})()
"""
SG_SYS_JS = r"""
(() => {
const S = window.__LGS_SG;
if (!S) return null;
const o = { version: S.version, caps: S.caps || [] };
try { o.dump = S.dump(); } catch (e) { o.dumpError = e.message; }
try { if (S.geom) o.geom = S.geom(); } catch (_) { /* old build */ }
try { if (S.spec) o.spec = S.spec(); } catch (_) { /* not yet (REQ P10->P7) */ }
return o;
})()
"""
def sgcheck_live(args):
route = opt(args, "--route")
pre = opt(args, "--pre")
settle = float(opt(args, "--settle", 1.5))
flag(args, "--json")
res = {"route": route, **stamp(),
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"]}}
try:
with open(os.path.join(lab.HERE, "sgcheck.json"), encoding="utf-8") as f:
extra = json.load(f).get("forbidden", {})
except (OSError, ValueError):
extra = {}
with Lock(both=True):
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, "main")
time.sleep(settle) # depth springs settle (at rest)
rep = lab_js("(window.__LGS_LAYERS && typeof window.__LGS_LAYERS.snapshot === 'function') ? "
"window.__LGS_LAYERS.snapshot() : null")
if not rep:
print(json.dumps(res))
print("BLOCKED: native layer off (no reporter: run sgcheck as a native-session step)")
return 3
res["report"] = rep
names = [s.get("name") for s in rep.get("surfaces", []) if s.get("name")]
res["dom"] = lab_js(SG_DOM_JS % {"names": json.dumps(names), "extra": json.dumps(extra)})
try:
res["sg"] = lab_js(SG_SYS_JS, surface="vr:systemui")
except SystemExit as e:
res["sg"] = {"error": str(e)}
if route:
lab_js(f"L.nav({json.dumps(route)})")
print("@@sgmodel " + json.dumps(res), flush=True)
return 0
# ---------------------------------------------------------------- cmp (live rects, optional capture)
CMP_RECTS_JS = r"""
(() => {
const out = {};
for (const it of %s) {
let w;
try { w = L.surface(it.surface || 'main'); } catch (e) { out[it.id] = { error: e.message }; continue; }
let s = it.sel, pick = 0;
const m = /@(last|nth=(\d+))$/.exec(s);
if (m) { s = s.slice(0, m.index); pick = m[1] === 'last' ? -1 : parseInt(m[2], 10); }
let els;
try { els = L.qa(it.surface || 'main', s).filter((e) => L.visible(w, e)); } catch (e) { out[it.id] = { error: e.message }; continue; }
const el = pick === -1 ? els[els.length - 1] : els[pick];
if (!el) { out[it.id] = { error: 'nothing visible matches ' + it.sel }; continue; }
const r = el.getBoundingClientRect();
out[it.id] = { surface: it.surface || 'main', rect: [r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10), dpr: w.devicePixelRatio, n: els.length };
}
return out;
})()
"""
def cmp_rects(args):
route = opt(args, "--route")
pre = opt(args, "--pre")
items = json.loads(opt(args, "--items") or "[]")
cap = opt(args, "--capture") # SURF:NAME -> shots/NAME.png
settle = float(opt(args, "--settle", 0.8))
res = {"route": route, **stamp(),
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"]}}
surf = cap.split(":", 1)[0] if cap else (items[0].get("surface", "main") if items else "main")
with Lock(surface=surf):
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, surf)
time.sleep(settle)
res["rects"] = lab_js(CMP_RECTS_JS % json.dumps(items), surface=surf_js(surf))
if cap:
s, name = cap.split(":", 1)
remote = capture_remote(s, name)
res["file"] = f"shots/{name}.png"
announce_file(remote, res["file"])
try:
lab_js("L.unhover()", surface=surf_js(surf))
except Exception: # noqa: BLE001
pass
if route:
lab_js(f"L.nav({json.dumps(route)})")
print("@@cmprects " + json.dumps(res), flush=True)
return 0
# ---------------------------------------------------------------- conformance (one route per step)
# The route's bottom ornament and glass bottom (PLAN R2-11): an ornament is a rendered #Footer legend (C1a's bottom
# ornament restyles Steam's #Footer); the glass bottom is C1a's --lgs-c1a-gh where it is published, else the window.
LAYOUT_JS = r"""
(() => {
const w = L.surface('main'), d = w.document, f = d.querySelector('#Footer');
const legends = f && L.visible(w, f) ? [...f.querySelectorAll('*')].filter((e) => !e.children.length && (e.textContent || '').trim() && L.visible(w, e)).length : 0;
let gh = NaN;
for (const el of [d.documentElement, (() => { try { return L.q('main', '%{BasicUiRoot}'); } catch (_) { return null; } })()]) {
if (el) { const v = parseFloat(w.getComputedStyle(el).getPropertyValue('--lgs-c1a-gh')); if (v > 0) gh = v; }
}
return { ornament: legends > 0, legends, glassBottom: gh > 0 ? gh : w.innerHeight, height: w.innerHeight };
})()
"""
def conformance(args):
route = opt(args, "--route")
pre = opt(args, "--pre")
surface = opt(args, "--surface", "main")
settle = float(opt(args, "--settle", 1.0))
flag(args, "--json")
sj = surf_js(surface)
S = json.dumps(surface)
res = {"route": route, "surface": surface, **stamp(),
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"]), "stock": STEP["stock"]},
"gates": {}}
g = res["gates"]
with Lock(surface=surface):
res.update(stamp()) # build, date and native as the step runs: the lock wait can be minutes (session 5)
lab_js(f"(L.gates.exemptions({json.dumps(load_exemptions())}), 1)", surface=sj)
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, surface)
if STEP["mode"] == "pad" and not sj:
lab_js("L.root()")
time.sleep(settle)
res["route"] = route or lab_js("L.route()")
rest = lab_js(f"L.gates.atRest({S})", surface=sj)
css = lab_js(f"L.gates.cssAudit({S})", surface=sj)
g["MOTION"] = {"atRest": rest, "cssNonToken": css}
g["SIZE"] = lab_js(f"L.gates.size({S})", surface=sj)
g["TYPE"] = lab_js(f"L.gates.type({S})", surface=sj)
g["OUTLINE"] = lab_js(f"L.gates.outline({S})", surface=sj)
res["conf"] = lab_js(f"L.conf.run({S}, {json.dumps({'mode': STEP['mode']})})", surface=sj)
if not sj:
res["layout"] = lab_js(LAYOUT_JS) # P-23's bottom bound (PLAN R2-11, REQ Coordinator->P10 (2))
if not STEP["stock"] and not sj:
lab.set_theme("off", surface)
time.sleep(0.4)
lab_js(f"(window.__LGS_AUDIT = L.snap({S}), 1)", surface=sj)
lab.set_theme("on", surface)
time.sleep(0.8)
g["AUD"] = lab_js(f"L.gates.audDiff(window.__LGS_AUDIT, L.snap({S}))", surface=sj)
g["AUD"].pop("moved", None)
if not g["AUD"].get("stockRecords", 1) or not g["AUD"].get("themedRecords", 1): # vacuous (session 5)
g["AUD"]["pass"] = False
g["AUD"].setdefault("issues", []).append(f"VACUOUS: an empty {surface} snapshot (stock or themed)")
if not sj:
rep = lab_js("(window.__LGS_LAYERS && typeof window.__LGS_LAYERS.snapshot === 'function') ? "
"window.__LGS_LAYERS.snapshot() : null")
if rep:
names = [s.get("name") for s in rep.get("surfaces", []) if s.get("name")]
res["sgmodel"] = {"report": rep, "dom": lab_js(SG_DOM_JS % {"names": json.dumps(names), "extra": "{}"})}
try:
with vr_lock(): # systemui is lab-vr.lock's (review R1 m1); order lab -> lab-vr
res["sgmodel"]["sg"] = lab_js(SG_SYS_JS, surface="vr:systemui")
except SystemExit as e:
res["sgmodel"]["sg"] = {"error": str(e)}
print("@@conf " + json.dumps(res), flush=True)
return 0
# ---------------------------------------------------------------- perf --ab (PLAN R2-13, REQ Coordinator->P10 (3))
def _median(xs):
xs = sorted(xs)
n = len(xs)
return None if not n else (xs[n // 2] if n % 2 else (xs[n // 2 - 1] + xs[n // 2]) / 2)
def perf_ab_verdict(runs):
"""R2-13's statistic over the pooled runs ({mode: ref|sub, fps, long, native}): only `native == "off"` runs count
(a CSS-only verdict). PASS: median fps ratio sub / ref >= 0.95 and median extra long frames (> 34 ms) <= the
reference's A/A spread (max - min of its long frames, min 1). None when either side has fewer than 2 runs."""
use = [r for r in runs if r.get("native") == "off"]
ref = [r for r in use if r["mode"] == "ref"]
sub = [r for r in use if r["mode"] == "sub"]
out = {"pooled": len(use), "excludedNative": len(runs) - len(use)}
if len(ref) < 2 or len(sub) < 2:
out.update({"pass": None, "why": f"{len(ref)} reference and {len(sub)} subject runs with native=off (need 2 each)"})
return out
rf, sf = [r["fps"] for r in ref], [r["fps"] for r in sub]
rl, sl = [r["long"] for r in ref], [r["long"] for r in sub]
ratio = _median(sf) / max(0.1, _median(rf))
extra = _median(sl) - _median(rl)
spread = max(1, max(rl) - min(rl))
out.update(fpsMedian={"ref": _median(rf), "sub": _median(sf)}, fpsRatio=round(ratio, 3),
longMedian={"ref": _median(rl), "sub": _median(sl)}, extraLong=extra, aaSpread=spread)
out["pass"] = ratio >= 0.95 and extra <= spread
return out
def perf_ab(surface, route, pre, secs, ab, rounds):
"""`perf SURF --ab stock|theme [--rounds N]`: ABBA x 2 per round (ref sub sub ref, twice), a further round pooled
while the verdict fails (at most N rounds, default 2). ref/sub: `stock` = theme off / theme on (G-PERF);
`theme` = theme only (runtime off) / theme + runtime (RT-7). Every run prints fps, long frames and `native`;
the verdict is R2-13's (perf_ab_verdict). Exit 0 PASS, 1 FAIL, 3 BLOCKED. The theme is given back as found."""
import lgs
if ab not in ("stock", "theme"):
print("usage: lab.py perf SURF --ab stock|theme [--rounds N] [--route R] [--pre JS] [--seconds S]")
return 2
if surface.startswith("vr:"):
print("BLOCKED: perf --ab compares Steam UI states; vr: pages have no stock/runtime toggle here")
return 3
sj = surf_js(surface)
S = json.dumps(surface)
ms = int(secs * 1000)
runs = []
res = {"surface": surface, "route": route, "ab": ab, **stamp(),
"step": {"flags": dict(STEP["flags"]), "mode": STEP["mode"], "media": list(STEP["media"])}}
step = {}
def put(mode):
if ab == "stock":
lgs.op("off" if mode == "ref" else "on", quiet=True)
else:
lgs.op("on", quiet=True, rt=(mode == "sub"))
# each `lgs on` starts a new runtime: the subject runs get the step's --flags overlay, --mode stub and the
# action logger again (the new runtime reads only the flags file); the reference has no runtime (stock) or
# runs without it (theme only)
if mode == "sub" and step.get("obj") is not None:
step["last"] = step["obj"].reapply_runtime()
time.sleep(0.4)
def one(mode, rnd):
put(mode)
tmp = remote_shot_path(f"_perf_{os.getpid()}")
try:
import asyncio
asyncio.run(lab.capture(surface, tmp, 0.8)) # brings the surface to the front (as plain perf)
finally:
try:
os.remove(tmp)
except OSError:
pass
p = lab_js(f"L.perf({S}, {ms})", timeout=60 + secs, surface=sj)
r = {"round": rnd, "mode": mode, "fps": p.get("fps"), "long": p.get("long"), "p95": p.get("p95"),
"median": p.get("median"), "worst": p.get("worst"), "native": lab.native_on()}
if mode == "sub" and step.get("last"):
r["reapplied"] = step["last"] # the step's runtime options in this run's runtime
runs.append(r)
print(f"run {len(runs):2d} r{rnd} {mode}: {r['fps']} fps, {r['long']} long frames, p95 {r['p95']} ms, "
f"native {r['native']}", flush=True)
v = None
with Lock(surface=surface) as lk:
step["obj"] = lk.step # None when nested in another lab step (its options are that step's)
# checked once the lock is held (the wait can be minutes): a CSS-only verdict pools only native=off runs
# (R2-13), and toggling the theme under another agent's native session would make its daemon dormant
res.update(stamp())
if res["native"] == "on":
print("BLOCKED: a native session is on (R2-13 pools only native=off runs); run perf --ab when it has ended")
return 3
was_on = lgs.is_on()
try:
if route:
lab_js(f"L.nav({json.dumps(route)})")
time.sleep(1.2)
if pre or STEP.get("hover"):
res["preResult"] = run_pre(pre, surface)
time.sleep(0.6)
stop = False
for rnd in range(1, max(1, rounds) + 1):
for mode in ("ref", "sub", "sub", "ref") * 2:
one(mode, rnd)
if runs[-1]["native"] == "on": # a native session began: stop toggling the theme
print("perf --ab: a native session began during the runs; stopped", flush=True)
stop = True
break
v = perf_ab_verdict(runs)
if stop or v.get("pass"):
break
finally:
try:
lgs.op("on" if was_on else "off", quiet=True) # as found (the default runtime with it)
except Exception as e: # noqa: BLE001
print(f"perf --ab: theme not given back: {e}", file=sys.stderr)
res.update(runs=runs, rounds=max(r["round"] for r in runs) if runs else 0, verdict=v)
ok = (v or {}).get("pass")
ref_name = "stock" if ab == "stock" else "theme only (runtime off)"
if ok is None:
print(f"perf --ab {ab}: BLOCKED: {(v or {}).get('why')}")
else:
print(f"perf --ab {ab} {surface} {route or ''} (build {res['build']}, {res['date']}): reference = {ref_name}; "
f"fps median ref {v['fpsMedian']['ref']} / subject {v['fpsMedian']['sub']} = {v['fpsRatio']} (needs >= 0.95); "
f"long frames median ref {v['longMedian']['ref']} / subject {v['longMedian']['sub']}: extra {v['extraLong']} "
f"(needs <= A/A spread {v['aaSpread']}); pooled {v['pooled']} runs over {res['rounds']} round(s), "
f"{v['excludedNative']} excluded (native on) -> {'PASS' if ok else 'FAIL'}")
print("@@perfab " + json.dumps(res), flush=True)
return 3 if ok is None else (0 if ok else 1)
COMMANDS = {
"conformance": conformance,
"cmp-rects": cmp_rects,
"sgcheck": sgcheck_live,
"motion": motion,
"native-session": native_session,
"hv-grab": hv_grab,
"gates": gates,
"pad-bfs": pad_bfs,
"focus": focus_live,
}
def run(cmd, args):
fn = COMMANDS.get(cmd)
if not fn:
print(f"BLOCKED: lab.py {cmd} is not implemented yet")
sys.exit(3)
code = fn(args)
if code:
sys.exit(code)