196 lines
9.4 KiB
Python
196 lines
9.4 KiB
Python
"""Run repeatable Chromium checks against the real javaTest visualizer server."""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from glob import glob
|
|
|
|
from playwright.sync_api import expect, sync_playwright
|
|
|
|
|
|
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:18084"
|
|
OUTPUT_DIR = sys.argv[2] if len(sys.argv) > 2 else "outputs/browser-qa"
|
|
|
|
|
|
def require(condition, message):
|
|
"""Fail the QA run with a business-readable assertion message."""
|
|
if not condition:
|
|
raise AssertionError(message)
|
|
|
|
|
|
def horizontal_overflow(page):
|
|
"""Return true when the document spills outside the active viewport."""
|
|
return page.evaluate(
|
|
"document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
|
|
)
|
|
|
|
|
|
def run_point_to_point(page):
|
|
"""Execute the real point simulation and verify route evidence is rendered."""
|
|
page.locator("#swap-sites").click()
|
|
page.locator("#run-point").click()
|
|
page.locator("#task-status").wait_for(state="visible", timeout=120_000)
|
|
page.locator("#loading-state").wait_for(state="hidden", timeout=120_000)
|
|
expect(page.locator("#task-status")).to_have_text("COMPLETED", timeout=120_000)
|
|
require(page.locator("#route-candidate-body tr").count() > 0,
|
|
"point simulation did not render route candidates")
|
|
require(page.locator("#route-quality-body tr").count() == 6,
|
|
"route quality tuple must contain six ordered metrics")
|
|
require(page.locator("#route-comparability").inner_text() != "--",
|
|
"historical comparability badge was not populated")
|
|
require("DIRECT" in page.locator("#evidence-assignment-mode").inner_text(),
|
|
"same-floor point simulation did not expose DIRECT assignment mode")
|
|
|
|
|
|
def verify_map_interactions(page):
|
|
"""Check full-map authority, route layers, viewport controls and timeline stability."""
|
|
map_edges = page.locator(".map-floor-layer:not([hidden]) .map-edge").count()
|
|
dynamic_edges = page.locator("#dynamic-route-layer .route-segment").count()
|
|
require(map_edges > 20, "complete floor map is missing")
|
|
require(map_edges > dynamic_edges, "map background should exceed one task route")
|
|
|
|
full_view = page.locator("#route-map").get_attribute("viewBox")
|
|
page.locator("#path-fit").click()
|
|
path_view = page.locator("#route-map").get_attribute("viewBox")
|
|
require(path_view != full_view, "path fit did not change the map viewport")
|
|
page.locator("#zoom-in").click()
|
|
zoomed_view = page.locator("#route-map").get_attribute("viewBox")
|
|
require(zoomed_view != path_view, "zoom control did not change the viewport")
|
|
page.locator("#reset-view").click()
|
|
require(page.locator("#route-map").get_attribute("viewBox") == "0 0 1000 560",
|
|
"reset did not restore the full-map viewport")
|
|
|
|
page.locator("#show-current-route").uncheck()
|
|
require(page.locator("#dynamic-route-layer .planned-only").count() == 0,
|
|
"current route remained visible after its layer was disabled")
|
|
require(page.locator("#dynamic-route-layer .approach").count() == 0,
|
|
"approach route remained visible after the current layer was disabled")
|
|
require(page.locator(".map-floor-layer:not([hidden]) .map-edge").count() == map_edges,
|
|
"disabling current route changed the authoritative map")
|
|
page.locator("#show-current-route").check()
|
|
|
|
page.locator("#show-historical").uncheck()
|
|
require(page.locator("#dynamic-route-layer .historical-only").count() == 0,
|
|
"historical route remained visible after its layer was disabled")
|
|
page.locator("#show-historical").check()
|
|
|
|
page.locator("#show-initial-route").uncheck()
|
|
require(page.locator("#dynamic-route-layer .initial-service").count() == 0,
|
|
"initial route remained visible after its layer was disabled")
|
|
page.locator("#show-initial-route").check()
|
|
|
|
before_tick_view = page.locator("#route-map").get_attribute("viewBox")
|
|
slider = page.locator("#timeline-slider")
|
|
slider.fill(slider.get_attribute("max"))
|
|
slider.dispatch_event("input")
|
|
require(page.locator("#route-map").get_attribute("viewBox") == before_tick_view,
|
|
"timeline movement unexpectedly changed the user viewport")
|
|
page.locator("#show-move-trace").uncheck()
|
|
require(page.locator("#dynamic-route-layer .traversed").count() == 0,
|
|
"MOVE trace remained visible after its layer was disabled")
|
|
page.locator("#show-move-trace").check()
|
|
|
|
|
|
def verify_historical_mode(page):
|
|
"""Run 240 tasks and inspect one real cross-floor dispatch without drawing a fake edge."""
|
|
page.locator(".mode-tab[data-mode='history']").click()
|
|
page.locator("#run-history").click()
|
|
expect(page.locator("#history-run-state")).to_have_text(
|
|
re.compile(r"^最近运行"), timeout=180_000
|
|
)
|
|
require("240" in page.locator("#task-filter-count").inner_text(),
|
|
"historical result did not expose all 240 tasks")
|
|
require(page.locator("#history-task-body tr").count() == 240,
|
|
"historical task table row count is not 240")
|
|
transfer_rows = page.locator(
|
|
"#history-task-body tr[data-assignment-mode='FLOOR_TRANSFER']"
|
|
)
|
|
require(transfer_rows.count() > 0,
|
|
"historical result did not expose a FLOOR_TRANSFER task")
|
|
transfer_rows.first.click()
|
|
require(page.locator("#route-candidate-body tr").count() > 0,
|
|
"selected historical task lost route candidates")
|
|
require("FLOOR_TRANSFER" in page.locator("#evidence-assignment-mode").inner_text(),
|
|
"selected transfer task did not expose its assignment mode")
|
|
expect(page.locator("#floor-transfer-panel")).to_be_visible()
|
|
require(page.locator("#floor-transfer-list .floor-transfer-job").count() > 0,
|
|
"selected transfer task did not render its transfer job")
|
|
require("→" in page.locator("#floor-transfer-list").inner_text()
|
|
and "幂等键" in page.locator("#floor-transfer-list").inner_text(),
|
|
"transfer job is missing port direction or idempotency evidence")
|
|
require(page.locator("#floor-select option", has_text="换层").count() >= 2,
|
|
"floor selector did not mark all floors involved in the transfer")
|
|
require(page.locator("#dynamic-route-layer .floor-transfer-port-marker").count() > 0,
|
|
"selected floor did not render a transfer port marker")
|
|
transfer_events = page.locator(
|
|
"#event-table-body tr[data-event-type^='FLOOR_TRANSFER']"
|
|
)
|
|
require(transfer_events.count() >= 3,
|
|
"transfer lifecycle events are missing from the timeline")
|
|
require(page.locator(
|
|
"#event-type-filter option[value='FLOOR_TRANSFER_COMPLETED']"
|
|
).inner_text() == "换层完成",
|
|
"transfer completion event did not receive a business label")
|
|
|
|
cross_floor_segments = page.evaluate("""
|
|
async () => {
|
|
const bootstrap = await fetch('/api/bootstrap').then(response => response.json());
|
|
const floors = Object.fromEntries(
|
|
bootstrap.sites.map(site => [site.siteId, Number(site.floor)]));
|
|
return Array.from(document.querySelectorAll(
|
|
'#dynamic-route-layer .route-segment title'))
|
|
.map(title => title.textContent.split(' → '))
|
|
.filter(parts => parts.length === 2
|
|
&& floors[parts[0]] !== undefined
|
|
&& floors[parts[1]] !== undefined
|
|
&& floors[parts[0]] !== floors[parts[1]]);
|
|
}
|
|
""")
|
|
require(not cross_floor_segments,
|
|
"normal route layer drew cross-floor edges: " + str(cross_floor_segments))
|
|
|
|
|
|
def verify_viewport(browser, viewport, filename, run_history):
|
|
"""Verify one desktop or mobile viewport and save its full-page evidence image."""
|
|
context = browser.new_context(viewport=viewport, locale="zh-CN")
|
|
page = context.new_page()
|
|
errors = []
|
|
page.on("console", lambda message: errors.append(message.text)
|
|
if message.type == "error" else None)
|
|
page.on("pageerror", lambda error: errors.append(str(error)))
|
|
page.goto(BASE_URL, wait_until="networkidle", timeout=120_000)
|
|
page.locator(".map-floor-layer:not([hidden]) .map-edge").first.wait_for(
|
|
state="attached", timeout=60_000
|
|
)
|
|
require(not horizontal_overflow(page), "page has horizontal overflow before simulation")
|
|
run_point_to_point(page)
|
|
verify_map_interactions(page)
|
|
if run_history:
|
|
verify_historical_mode(page)
|
|
require(not horizontal_overflow(page), "page has horizontal overflow after simulation")
|
|
page.screenshot(path=os.path.join(OUTPUT_DIR, filename), full_page=True)
|
|
require(not errors, "browser console/page errors: " + " | ".join(errors))
|
|
context.close()
|
|
|
|
|
|
def main():
|
|
"""Run desktop and mobile checks using one headless Chromium process."""
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
with sync_playwright() as playwright:
|
|
cached_candidates = sorted(glob(os.path.join(
|
|
os.environ.get("LOCALAPPDATA", ""), "ms-playwright", "chromium-*",
|
|
"chrome-win64", "chrome.exe"
|
|
)), reverse=True)
|
|
launch_options = {"headless": True}
|
|
if cached_candidates:
|
|
launch_options["executable_path"] = cached_candidates[0]
|
|
browser = playwright.chromium.launch(**launch_options)
|
|
verify_viewport(browser, {"width": 1440, "height": 1000}, "desktop.png", True)
|
|
verify_viewport(browser, {"width": 390, "height": 844}, "mobile.png", False)
|
|
browser.close()
|
|
print("visualizer browser QA passed: desktop=1440x1000 mobile=390x844")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|