Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""Снимает и размечает скриншоты веб-интерфейса (desktop) и мобильной версии /m.
|
||||
|
||||
Запуск: python tools/guide/shots_web.py [имя_скриншота ...]
|
||||
Нужен запущенный сервер с тестовыми данными (tools/guide/seed_server.py) на http://localhost:8000.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from annotate import annotate, annotate_side # noqa: E402
|
||||
from cdp import Browser, Page # noqa: E402
|
||||
from spec import DESKTOP, MOBILE, PASS, Shot # noqa: E402
|
||||
|
||||
BASE = os.environ.get("GUIDE_BASE", "http://localhost:8000")
|
||||
OUT = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img"
|
||||
ANDROID_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Mobile Safari/537.36"
|
||||
|
||||
FIND = """((sel,text,nth)=>{const els=[...document.querySelectorAll(sel)].filter(e=>{const t=(e.innerText||'')+' '+(e.value||'')+' '+(e.placeholder||'');return !text||t.includes(text)})
|
||||
.filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0});const e=els[nth];if(!e)return null;const r=e.getBoundingClientRect();return [r.x,r.y,r.width,r.height]})"""
|
||||
|
||||
|
||||
def api_login(user):
|
||||
req = urllib.request.Request(BASE + "/api/v1/auth/login", data=json.dumps({"username": user, "password": PASS.get(user, "demo1234")}).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
|
||||
async def find(page: Page, sel, text, nth):
|
||||
return await page.eval(f"{FIND}({json.dumps(sel)},{json.dumps(text)},{nth})")
|
||||
|
||||
|
||||
async def reset(page: Page):
|
||||
await page.goto("about:blank", 0.2)
|
||||
await page.call("Storage.clearDataForOrigin", {"origin": BASE, "storageTypes": "all"})
|
||||
|
||||
|
||||
async def run_steps(page: Page, shot: Shot):
|
||||
mobile = shot.device == "mobile"
|
||||
for st in shot.steps:
|
||||
op = st[0]
|
||||
if op == "reset":
|
||||
await reset(page)
|
||||
await page.goto(BASE + ("/m" if mobile else "/"), 1.5)
|
||||
elif op == "hash":
|
||||
if mobile:
|
||||
await page.eval(f"location.hash={json.dumps(st[1])}")
|
||||
await asyncio.sleep(0.8)
|
||||
else:
|
||||
await page.goto(BASE + "/" + st[1], 1.2)
|
||||
elif op == "js":
|
||||
await page.eval(st[1]); await asyncio.sleep(0.6)
|
||||
elif op == "wait":
|
||||
await asyncio.sleep(st[1])
|
||||
elif op == "scroll":
|
||||
await page.eval(f"""(()=>{{const els=[...document.querySelectorAll({json.dumps(st[1])})].filter(e=>!{json.dumps(st[2])}||(e.innerText||'').includes({json.dumps(st[2])}));
|
||||
const e=els[{st[3]}]; if(!e) throw new Error('scroll: нет {st[1]}'); e.scrollIntoView({{block:'start'}}); window.scrollBy(0,-84);}})()""")
|
||||
await asyncio.sleep(0.4)
|
||||
elif op == "click":
|
||||
r = await find(page, st[1], st[2], st[3])
|
||||
if not r: raise RuntimeError(f"click: не найден {st[1]!r} {st[2]!r}")
|
||||
await page.eval(f"""(()=>{{const els=[...document.querySelectorAll({json.dumps(st[1])})].filter(e=>!{json.dumps(st[2])}||(e.innerText||'').includes({json.dumps(st[2])}));els[{st[3]}].click();}})()""")
|
||||
await asyncio.sleep(0.9)
|
||||
elif op == "m_login":
|
||||
await page.goto(BASE + "/m", 1.5)
|
||||
await page.eval(f"""(()=>{{const [u,p]=document.querySelectorAll('input'); u.value={json.dumps(st[1])}; p.value={json.dumps(st[2])}; document.querySelector('button.block').click();}})()""")
|
||||
await asyncio.sleep(4.5)
|
||||
elif op == "offline":
|
||||
await page.call("Network.enable")
|
||||
await page.call("Network.emulateNetworkConditions", {"offline": st[1], "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1})
|
||||
else:
|
||||
raise ValueError(op)
|
||||
|
||||
|
||||
async def run_shot(page: Page, shot: Shot):
|
||||
mobile = shot.device == "mobile"
|
||||
dpr = 2 if mobile else 1
|
||||
await page.emulate(390 if mobile else 1280, shot.height or (844 if mobile else 860), dpr, mobile, ANDROID_UA if mobile else None)
|
||||
await page.call("Emulation.setEmulatedMedia", {"features": [{"name": "prefers-color-scheme", "value": "light"}]})
|
||||
await page.call("Network.enable")
|
||||
await page.call("Network.emulateNetworkConditions", {"offline": False, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1})
|
||||
first = shot.steps[0][0] if shot.steps else ""
|
||||
if first != "reset" and first != "m_login":
|
||||
await reset(page)
|
||||
if shot.user and not mobile:
|
||||
t = api_login(shot.user)
|
||||
await page.goto(BASE + "/", 0.8)
|
||||
await page.eval(f"localStorage.setItem('token',{json.dumps(t['access_token'])}); localStorage.setItem('refresh',{json.dumps(t['refresh_token'])});")
|
||||
await page.call("Page.reload")
|
||||
await asyncio.sleep(1.2)
|
||||
if mobile and first == "m_login":
|
||||
await reset(page)
|
||||
await run_steps(page, shot)
|
||||
# убрать подсветку фокуса/каретку
|
||||
await page.eval("document.activeElement && document.activeElement.blur && document.activeElement.blur(); window.getSelection().removeAllRanges()")
|
||||
marks = []
|
||||
for i, m in enumerate(shot.marks, 1):
|
||||
r = await find(page, m.sel, m.text, m.nth)
|
||||
if not r:
|
||||
raise RuntimeError(f"[{shot.name}] метка {i} не найдена: sel={m.sel!r} text={m.text!r} nth={m.nth}")
|
||||
x, y, w, h = r
|
||||
w, h = w + m.grow[0], h + m.grow[1]
|
||||
marks.append((i, (x * dpr, y * dpr, w * dpr, h * dpr), m.dir))
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
raw = OUT / f"{shot.name}.raw.png"
|
||||
await page.screenshot(str(raw))
|
||||
shot_img = Image.open(raw)
|
||||
img = annotate_side(shot_img, marks, margin=110, scale=dpr * 0.9) if mobile else annotate(shot_img, marks, scale=1.0)
|
||||
if shot.crop:
|
||||
x, y, w, h = shot.crop
|
||||
img = img.crop((x, y, x + w, y + h))
|
||||
img.save(OUT / f"{shot.name}.png", optimize=True)
|
||||
raw.unlink()
|
||||
print("ok", shot.name, img.size, flush=True)
|
||||
|
||||
|
||||
async def main(names):
|
||||
b = Browser()
|
||||
b.start()
|
||||
try:
|
||||
page = Page(b.page_ws())
|
||||
await page.open()
|
||||
shots = [s for s in MOBILE + DESKTOP if not names or s.name in names] # мобильные первыми: им нужны «свежие» наряды
|
||||
for s in shots:
|
||||
try:
|
||||
await run_shot(page, s)
|
||||
except Exception as e:
|
||||
print("FAIL", s.name, e, flush=True)
|
||||
await page.close()
|
||||
finally:
|
||||
b.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(set(sys.argv[1:])))
|
||||
Reference in New Issue
Block a user