lct-hack/scripts/smoke_mobile.py
2026-09-24 01:10:49 +03:00

343 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Browser smoke for the local demo, including mobile layout and DDS workflow."""
import argparse
import json
import time
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from playwright.sync_api import sync_playwright
from load_browser import local_url
def layout(page) -> dict:
return page.evaluate(
"""() => {
const root = document.documentElement;
const visible = [...document.querySelectorAll('body *')].filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const closed = element.closest('details:not([open])');
return (!closed || element.tagName === 'SUMMARY') && style.display !== 'none'
&& style.visibility !== 'hidden' && rect.width > 0;
});
return {
viewport: innerWidth,
document_width: root.scrollWidth,
overflowing: visible.filter((element) => {
const rect = element.getBoundingClientRect();
return rect.right > innerWidth + 2 || rect.left < -2;
}).slice(0, 20).map((element) => ({
tag: element.tagName.toLowerCase(),
class: element.className || '',
text: (element.textContent || '').trim().slice(0, 80),
left: Math.round(element.getBoundingClientRect().left),
right: Math.round(element.getBoundingClientRect().right),
})),
small_controls: [...document.querySelectorAll(
'button, select, textarea, input:not([type=radio]):not([type=checkbox]), summary'
)].filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden'
&& !element.disabled && (rect.height < 40 || rect.width < 40);
}).slice(0, 20).map((element) => ({
tag: element.tagName.toLowerCase(),
label: element.getAttribute('aria-label') || element.textContent?.trim().slice(0, 60)
|| element.getAttribute('placeholder') || '',
width: Math.round(element.getBoundingClientRect().width),
height: Math.round(element.getBoundingClientRect().height),
})),
};
}"""
)
def open_instructor(page, base_url: str) -> None:
page.goto(f"{base_url}/instructor", wait_until="networkidle")
demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
if demo_login.is_visible():
demo_login.click()
page.locator("#ai-scenario-editor").wait_for()
def stop_exercise_from_instructor(page, base_url: str, exercise_href: str) -> None:
session_id = parse_qs(urlparse(exercise_href).query).get("session", [None])[0]
if not session_id:
raise RuntimeError(f"в ссылке АРМ нет session id: {exercise_href}")
page.goto(f"{base_url}/instructor?session={session_id}", wait_until="networkidle")
page.get_by_role("button", name="Завершить занятие").click()
page.get_by_text("занятие завершено", exact=True).wait_for()
def choose_trainee(page) -> None:
# Match the label cell, not any row whose body happens to mention a
# trainee (the scenario/category tables include that word too).
exercise_table = page.locator(".instructor-layout > section").first.locator(
"table.grid"
).first
trainee_row = exercise_table.locator("th").get_by_text(
"Курсант", exact=True
).locator("xpath=..")
trainee_select = trainee_row.locator("select")
if trainee_select.count():
if trainee_select.locator("option").count() < 2:
raise RuntimeError("Список курсантов не содержит доступного выбора")
trainee_select.select_option(index=1)
else:
trainee_row.locator('input[type="text"]').fill("Мобильный smoke")
def start_exercise(page, label: str, link_row: str) -> str:
page.get_by_role("radio", name=label).check()
choose_trainee(page)
page.get_by_role("button", name="Начать занятие").click()
row = page.locator("tr").filter(has_text=link_row).last
link = row.get_by_role("link", name="открыть", exact=False)
link.wait_for()
href = link.get_attribute("href")
if not href:
raise RuntimeError(f"не получена ссылка {link_row}")
return href
def exercise_dds_flow(page, scenario_title: str) -> dict:
"""Accept the card, select its crew and complete the training briefing."""
service_button = page.locator(".dds-service-dock button").first
service_title = service_button.locator("b").inner_text()
service_button.click()
next_status = page.locator(
'.dds-status-editor select[aria-label^="Следующий статус"]'
)
next_status.wait_for()
labels = next_status.locator("option").all_text_contents()
if not any("Принята" in label for label in labels):
raise RuntimeError(f"статус «Принята» недоступен для {service_title}: {labels}")
next_status.select_option(value="accepted")
page.locator('button[aria-label="Сохранить статус"]').click()
page.wait_for_function("""() => [...document.querySelectorAll(
'.dds-service-dock button')].some((button) => button.innerText.includes('Принята'))""")
page.locator(".dds-service-dock button").filter(has_text="Связь").click()
crew_select = page.locator("#dds-crew")
crew_select.wait_for()
options = crew_select.locator("option").all_text_contents()
crew = next((option.strip() for option in options
if option.strip().startswith(service_title)), None)
if not crew:
raise RuntimeError(f"для {service_title} нет доступной бригады: {options}")
crew_select.select_option(label=crew)
page.get_by_role("button", name="Позвонить старшему группы").click()
address = page.locator(".dds-case-address > strong").inner_text().strip()
page.locator("#dds-phone-address").fill(address)
page.locator("#dds-phone-incident").fill(scenario_title)
page.locator("#dds-phone-request").fill(
"Подтвердите выезд и доложите о прибытии."
)
page.get_by_role("button", name="Передать сообщение").click()
transcript = page.locator(".dds-phone-transcript")
transcript.wait_for()
page.wait_for_function("""() => {
const transcript = document.querySelector('.dds-phone-transcript');
const alert = document.querySelector('.dds-alert[role="alert"]');
return transcript?.innerText.includes('Адрес:') || Boolean(alert?.innerText.trim());
}""", timeout=10_000)
text = transcript.inner_text()
if "Адрес:" not in text or "Старший группы" not in text:
alert = page.locator('.dds-alert[role="alert"]')
error = alert.inner_text() if alert.count() else "нет сообщения об ошибке"
raise RuntimeError(
f"учебный доклад не попал в журнал: {text}; ошибка: {error}; "
f"адрес={address!r}, происшествие={scenario_title!r}"
)
return {"service": service_title, "status": "Принята", "crew": crew,
"brief_recorded": True}
def smoke_dds_queue(page, base_url: str, output: Path, screenshot_suffix: str) -> dict:
"""Check per-card state isolation while a second card arrives in queue."""
open_instructor(page, base_url)
queue = page.locator("details.scenario-queue")
queue.locator("summary").click()
queue.locator('input[type="checkbox"]').first.check()
page.get_by_label("Интервал поступления карточек ДДС, секунд").fill("6")
page.get_by_label("Максимум ожидающих карточек ДДС").fill("1")
dds_href = start_exercise(page, "Диспетчер ДДС · готовая карточка", "АРМ ДДС")
page.goto(dds_href, wait_until="networkidle")
page.locator(".dds-workspace").wait_for()
first_row = page.locator('.dds-registry tbody tr[aria-current="true"]')
first_row.wait_for()
first_number = first_row.locator('td[data-label="Номер"] button').inner_text()
first_row.get_by_role("button", name="Открыть карточку").click()
page.locator(".dds-case-layout").wait_for()
first_status_button = page.locator(".dds-service-dock button").first
service_title = first_status_button.locator("b").inner_text()
first_status_button.click()
next_status = page.locator(
'.dds-status-editor select[aria-label^="Следующий статус"]'
)
next_status.wait_for()
service_name = next_status.get_attribute("aria-label").removeprefix("Следующий статус ")
if not any("Принята" in option for option in next_status.locator("option").all_text_contents()):
raise RuntimeError(f"для первой карточки недоступен статус «Принята»: {service_title}")
next_status.select_option(value="accepted")
page.locator('button[aria-label="Сохранить статус"]').click()
page.wait_for_function(
"""(service) => [...document.querySelectorAll('.dds-service-dock button')]
.some((button) => button.innerText.includes(service)
&& button.innerText.includes('Принята'))""",
arg=service_title,
timeout=5_000,
)
page.locator(".dds-case-top button").click()
page.wait_for_function(
"document.querySelectorAll('.dds-registry tbody tr').length === 2",
timeout=20_000,
)
rows = page.locator(".dds-registry tbody tr")
if rows.count() != 2 or page.locator('.dds-registry tbody tr[aria-current="true"]').count() != 1:
raise RuntimeError("ожидались две карточки в очереди и ровно одна активная")
second_row = rows.nth(1)
second_number = second_row.locator('td[data-label="Номер"] button').inner_text()
second_row.get_by_role("button", name="Открыть карточку").click()
page.locator(".dds-case-top").get_by_text(second_number, exact=False).wait_for()
page.locator(".dds-case-top button").click()
page.locator(".dds-registry tbody tr").filter(has_text=first_number).get_by_role(
"button", name="Открыть карточку"
).click()
page.locator(".dds-case-top").get_by_text(first_number, exact=False).wait_for()
restored_status = page.locator(".dds-service-dock button").filter(
has_text=service_title
).first.inner_text()
if "Принята" not in restored_status:
raise RuntimeError(f"статус первой карточки потерян после поступления/переключения: {restored_status}")
page.locator(".dds-case-top button").click()
screen = layout(page)
page.screenshot(path=output / f"dds-queue-{screenshot_suffix}.png", full_page=True)
stop_exercise_from_instructor(page, base_url, dds_href)
return {"cards_visible": 2, "active_cards": 1,
"arrival_interval_seconds": 6, "max_waiting": 1,
"managed_service": service_name,
"first_card_status_preserved_after_second_arrival": True,
"switched_to_second_and_back": True,
"session_finished": True, "screen": screen}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://127.0.0.1:5175")
parser.add_argument("--output", type=Path, default=Path("/tmp/lct-mobile-smoke"))
parser.add_argument("--report", type=Path)
parser.add_argument("--login")
parser.add_argument("--password")
parser.add_argument("--width", type=int, default=390)
parser.add_argument("--height", type=int, default=844)
parser.add_argument("--device-scale-factor", type=float, default=2)
args = parser.parse_args()
if args.width < 320 or args.height < 480 or args.device_scale_factor <= 0:
parser.error("viewport must be at least 320×480 and scale factor must be positive")
screenshot_suffix = "mobile" if args.width <= 600 else "desktop"
args.base_url = local_url(args.base_url, {"http", "https"})
args.output.mkdir(parents=True, exist_ok=True)
report = {
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"base_url": args.base_url,
"viewport": {
"width": args.width,
"height": args.height,
"device_scale_factor": args.device_scale_factor,
"touch_target_minimum_px": 40 if args.width <= 600 else None,
},
"screens": {},
"workflow": {},
}
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
context = browser.new_context(
viewport={"width": args.width, "height": args.height},
device_scale_factor=args.device_scale_factor,
locale="ru-RU",
)
page = context.new_page()
page.goto(f"{args.base_url}/instructor", wait_until="networkidle")
demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
if demo_login.is_visible():
demo_login.click()
elif args.login and args.password:
page.locator('input:not([type="password"])').first.fill(args.login)
page.locator('input[type="password"]').fill(args.password)
page.get_by_role("button", name="Войти", exact=True).click()
elif page.get_by_role("heading", name="Учебный симулятор системы-112").is_visible():
raise RuntimeError("stand requires --login and --password")
page.locator("#ai-scenario-editor").wait_for()
page.screenshot(path=args.output / f"instructor-{screenshot_suffix}.png", full_page=True)
report["screens"]["instructor"] = layout(page)
trainee_href = start_exercise(
page, "Оператор 112 · текстовая вводная", "АРМ курсанта"
)
page.goto(trainee_href, wait_until="networkidle")
page.locator(".kio-arm").wait_for()
page.screenshot(path=args.output / f"trainee-{screenshot_suffix}.png", full_page=True)
report["screens"]["trainee"] = layout(page)
stop_exercise_from_instructor(page, args.base_url, trainee_href)
open_instructor(page, args.base_url)
dds_href = start_exercise(
page, "Диспетчер ДДС · готовая карточка", "АРМ ДДС"
)
page.goto(dds_href, wait_until="networkidle")
page.locator(".dds-workspace").wait_for()
page.screenshot(path=args.output / f"dds-{screenshot_suffix}.png", full_page=True)
report["screens"]["dds"] = layout(page)
active_row = page.locator(".dds-registry tbody tr.dds-selected")
scenario_title = active_row.locator(
'td[data-label="Тип происшествия"] .dds-cell-value > span'
).inner_text().split(" · ", 1)[0]
page.get_by_role("button", name="Открыть карточку").first.click()
page.locator(".dds-case-layout").wait_for()
page.screenshot(path=args.output / f"dds-card-{screenshot_suffix}.png", full_page=True)
report["screens"]["dds_card"] = layout(page)
report["workflow"] = exercise_dds_flow(page, scenario_title)
page.screenshot(path=args.output / f"dds-workflow-{screenshot_suffix}.png", full_page=True)
report["screens"]["dds_workflow"] = layout(page)
page.locator(".dds-service-dock button").last.click()
page.locator(".dds-done").wait_for()
report["workflow"]["session_finished"] = True
queue = smoke_dds_queue(page, args.base_url, args.output, screenshot_suffix)
report["screens"]["dds_queue"] = queue.pop("screen")
report["queue"] = queue
browser.close()
problems = {
name: {
"horizontal_overflow": data["document_width"] > data["viewport"] + 2,
"small_controls": len(data["small_controls"]),
"touch_target_check_applies": args.width <= 600,
}
for name, data in report["screens"].items()
}
report["problems"] = problems
report["passed"] = all(
not item["horizontal_overflow"] and (
not item["touch_target_check_applies"] or item["small_controls"] == 0
)
for item in problems.values()
)
rendered = json.dumps(report, ensure_ascii=False, indent=2)
print(rendered)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(rendered + "\n", encoding="utf-8")
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())