257 lines
14 KiB
Python
257 lines
14 KiB
Python
"""Check the built site in real Chromium at desktop and phone widths."""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
from urllib.parse import unquote, urljoin, urlsplit
|
|
|
|
from playwright.sync_api import expect, sync_playwright
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def handle(self):
|
|
# Browser navigation can cancel an in-flight lazy image response.
|
|
try:
|
|
super().handle()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
|
|
class ScreenshotReferences(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.references = []
|
|
self.ids = set()
|
|
|
|
def handle_starttag(self, tag, attributes):
|
|
attrs = dict(attributes)
|
|
if attrs.get("id"):
|
|
self.ids.add(attrs["id"])
|
|
for attribute in ("src", "href"):
|
|
if attrs.get(attribute):
|
|
self.references.append(attrs[attribute])
|
|
|
|
|
|
def verify_screenshot_references(output, canonical):
|
|
"""Check gallery embeds on every static page, including optional translations."""
|
|
pages = {}
|
|
for path in output.rglob("*.html"):
|
|
page = ScreenshotReferences()
|
|
page.feed(path.read_text())
|
|
pages[path.resolve()] = page
|
|
for path, page in pages.items():
|
|
page_url = urljoin(canonical, path.relative_to(output.resolve()).as_posix())
|
|
for reference in page.references:
|
|
url = urlsplit(urljoin(page_url, reference))
|
|
if url.netloc != urlsplit(canonical).netloc or "/screenshots/" not in url.path:
|
|
continue
|
|
target = output / unquote(url.path).lstrip("/")
|
|
if target.is_dir():
|
|
target /= "index.html"
|
|
target = target.resolve()
|
|
assert target.is_relative_to(output.resolve()) and target.is_file(), f"{path}: missing screenshot target {reference}"
|
|
assert not re.match(r"^\d+-", target.name), f"{path}: screenshot filename depends on capture order: {reference}"
|
|
if url.fragment and target in pages:
|
|
assert unquote(url.fragment) in pages[target].ids, f"{path}: missing screenshot anchor {reference}"
|
|
|
|
|
|
def verify(output, evidence):
|
|
config = json.loads((HERE / "site.json").read_text())
|
|
verify_screenshot_references(output, config["url"])
|
|
evidence.mkdir(parents=True, exist_ok=True)
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(output.resolve())))
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
origin = f"http://127.0.0.1:{server.server_port}"
|
|
try:
|
|
with sync_playwright() as playwright:
|
|
browser = playwright.chromium.launch(executable_path=os.environ.get("CHROME_BINARY"), ignore_default_args=["--hide-scrollbars"])
|
|
for name in config["pages"]:
|
|
route = name.removesuffix("index.html")
|
|
page = browser.new_page(reduced_motion="reduce")
|
|
errors = []
|
|
missing = []
|
|
page.on("pageerror", lambda error, errors=errors: errors.append(str(error)))
|
|
page.on(
|
|
"response",
|
|
lambda response, missing=missing: (
|
|
missing.append(response.url) if response.url.startswith(origin) and response.status >= 400 else None
|
|
),
|
|
)
|
|
for width in [1716, 2560, 390]:
|
|
page.set_viewport_size({"width": width, "height": 900})
|
|
page.goto(f"{origin}/{route}", wait_until="domcontentloaded")
|
|
header = page.locator(".abx-header")
|
|
expect(header).to_have_count(1)
|
|
expect(header).to_be_visible()
|
|
expect(header).to_be_in_viewport()
|
|
brand = header.locator(".abx-brand")
|
|
expect(brand).to_be_visible()
|
|
expect(brand).to_contain_text("ArchiveBox")
|
|
expect(brand.locator(".abx-logo")).to_be_visible()
|
|
expect(brand.locator(".abx-logo")).to_be_in_viewport()
|
|
expect(header).to_have_css("display", "flex")
|
|
expect(page.locator(".abx-footer")).to_have_count(1)
|
|
expect(page.locator(".abx-footer-column")).to_have_count(3)
|
|
expect(header.locator(".abx-cta")).to_be_visible()
|
|
menu = header.locator(".abx-apps")
|
|
menu.locator("summary").focus()
|
|
page.keyboard.press("Enter")
|
|
expect(menu).to_have_attribute("open", "")
|
|
expect(menu.locator("a").first).to_be_visible()
|
|
page.keyboard.press("Escape")
|
|
expect(menu).not_to_have_attribute("open", "")
|
|
menu.locator("summary").click()
|
|
box = header.bounding_box()
|
|
assert box
|
|
page.mouse.click(box["x"] + 2, box["y"] + 5)
|
|
expect(menu).not_to_have_attribute("open", "")
|
|
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"), (
|
|
f"{name}: overflow at {width}"
|
|
)
|
|
if not route and "screenshots/index.html" in config["pages"]:
|
|
strips = page.locator(".abx-marquee")
|
|
assert strips.count() > 0, "Homepage must use its screenshot gallery"
|
|
for strip in strips.all():
|
|
strip.scroll_into_view_if_needed()
|
|
bounds = strip.bounding_box()
|
|
usable_width = page.evaluate("document.documentElement.clientWidth")
|
|
assert bounds and abs(bounds["x"]) < 1
|
|
assert abs(bounds["width"] - usable_width) < 1
|
|
assert not strip.inner_text().strip(), "Screenshot strips should have no visible captions or links"
|
|
cards = strip.locator(".abx-marquee-card")
|
|
assert cards.count() > 0, "Screenshot strip is empty"
|
|
expect(strip.locator("button")).to_have_count(0)
|
|
expect(strip.locator("a:not(.abx-marquee-card)")).to_have_count(0)
|
|
viewport = strip.locator(".abx-marquee-viewport")
|
|
overflowing = viewport.evaluate(
|
|
"node => node.scrollWidth > node.clientWidth",
|
|
)
|
|
if overflowing:
|
|
page.mouse.move(0, 0)
|
|
page.emulate_media(reduced_motion="no-preference")
|
|
initial = viewport.evaluate("node => node.scrollLeft")
|
|
page.wait_for_function(
|
|
"([node, initial]) => node.scrollLeft > initial",
|
|
arg=[viewport.element_handle(), initial],
|
|
)
|
|
viewport.hover()
|
|
stopped = viewport.evaluate("node => node.scrollLeft")
|
|
viewport.evaluate(
|
|
"node => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
|
)
|
|
assert viewport.evaluate("node => node.scrollLeft") == stopped
|
|
page.emulate_media(reduced_motion="reduce")
|
|
page.mouse.move(0, 0)
|
|
stopped = viewport.evaluate("node => node.scrollLeft")
|
|
viewport.evaluate(
|
|
"node => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
|
)
|
|
assert viewport.evaluate("node => node.scrollLeft") == stopped
|
|
viewport.focus()
|
|
page.keyboard.press("ArrowRight")
|
|
assert viewport.evaluate("node => node.scrollWidth > node.clientWidth")
|
|
else:
|
|
centered = viewport.evaluate(
|
|
"""node => {
|
|
const view = node.getBoundingClientRect();
|
|
const track = node.querySelector('.abx-marquee-track').getBoundingClientRect();
|
|
return Math.abs((track.left - view.left) - (view.right - track.right)) < 2;
|
|
}""",
|
|
)
|
|
assert centered, "Short screenshot strips must be centered"
|
|
for card in cards.all():
|
|
href = card.get_attribute("href")
|
|
assert href and "/screenshots/" in href
|
|
response = page.request.get(urljoin(page.url, href))
|
|
assert response.ok, f"Broken screenshot link: {href}"
|
|
anchor = urlsplit(href).fragment
|
|
if anchor:
|
|
assert f'id="{anchor}"' in response.text(), f"Missing screenshot anchor: {href}"
|
|
# Decode every local content image using the real browser loader,
|
|
# including images in offscreen carousel tracks and lazy galleries.
|
|
images = page.locator("main img").evaluate_all("nodes => [...new Set(nodes.map(image => image.src))]")
|
|
local_images = [url for url in images if url.startswith(origin + "/")]
|
|
page.evaluate(
|
|
"""async urls => {
|
|
for (const url of urls) {
|
|
const image = new Image();
|
|
image.src = url;
|
|
await image.decode();
|
|
if (!image.naturalWidth) throw new Error(`Broken image: ${url}`);
|
|
}
|
|
}""",
|
|
local_images,
|
|
)
|
|
page.evaluate("scrollTo({top: 0, behavior: 'instant'})")
|
|
page.evaluate(
|
|
"() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
|
)
|
|
page.screenshot(path=str(evidence / f"{route.replace('/', '-') or 'home-'}{width}.png"))
|
|
page.locator(".abx-footer").screenshot(path=str(evidence / f"{route.replace('/', '-') or 'home-'}footer-{width}.png"))
|
|
for link in header.locator(".abx-nav > a").all():
|
|
url = urlsplit(link.get_attribute("href") or "")
|
|
if url.scheme or url.netloc:
|
|
continue
|
|
response = page.request.get(urljoin(page.url, url.geturl()))
|
|
assert response.ok, f"Broken navigation: {url.geturl()}"
|
|
if url.fragment:
|
|
body = response.text()
|
|
assert f'id="{url.fragment}"' in body or f"id='{url.fragment}'" in body, (
|
|
f"Missing navigation anchor: {url.geturl()}"
|
|
)
|
|
if route == "" and page.locator("#resources").count():
|
|
page.goto(f"{origin}/#resources")
|
|
expect(page.locator("#resources")).to_be_visible()
|
|
assert not errors, errors
|
|
assert not missing, missing
|
|
page.close()
|
|
context = browser.new_context(java_script_enabled=False, viewport={"width": 390, "height": 900})
|
|
plain = context.new_page()
|
|
plain.goto(f"{origin}/{route}", wait_until="domcontentloaded")
|
|
expect(plain.locator(".abx-header")).to_be_visible()
|
|
expect(plain.locator(".abx-header .abx-brand")).to_be_in_viewport()
|
|
expect(plain.locator(".abx-header .abx-logo")).to_be_visible()
|
|
plain.locator(".abx-apps summary").click()
|
|
expect(plain.locator(".abx-app-links a").first).to_be_visible()
|
|
expect(plain.locator(".abx-footer-column a").first).to_be_visible()
|
|
if not route and "screenshots/index.html" in config["pages"]:
|
|
assert plain.locator(".abx-marquee-card").count() > 0
|
|
for strip in plain.locator(".abx-marquee").all():
|
|
bounds = strip.bounding_box()
|
|
usable_width = plain.evaluate("document.documentElement.clientWidth")
|
|
assert bounds and abs(bounds["x"]) < 1
|
|
assert abs(bounds["width"] - usable_width) < 1
|
|
assert plain.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth")
|
|
expect(plain.locator(".abx-marquee button")).to_have_count(0)
|
|
expect(plain.locator(".abx-marquee-card").first).to_be_visible()
|
|
context.close()
|
|
print(
|
|
f"PASS {name}: desktop/mobile, keyboard, no-JS, images and resources",
|
|
flush=True,
|
|
)
|
|
browser.close()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("output", type=Path)
|
|
parser.add_argument("--evidence", type=Path, default=Path("test-results/site"))
|
|
args = parser.parse_args()
|
|
verify(args.output, args.evidence)
|