118 lines
6.0 KiB
Python
118 lines
6.0 KiB
Python
"""Browser checks for the built frontend: python -m scripts.test_theme.
|
|
|
|
Requires Playwright and Microsoft Edge. Uses an isolated static server and mocked
|
|
API reads; never signs into or changes the live portfolio.
|
|
"""
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
from urllib.parse import urlsplit
|
|
|
|
from playwright.sync_api import expect, sync_playwright
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class StaticApp(SimpleHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if not Path(urlsplit(self.path).path).suffix:
|
|
self.path = "/index.html"
|
|
super().do_GET()
|
|
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
|
|
def run():
|
|
dist = ROOT / "frontend/dist"
|
|
assert (dist / "index.html").is_file(), "Build the frontend first."
|
|
fixtures = {
|
|
f"/api/{name}": json.loads((ROOT / f"backend/data/{name}.json").read_text(encoding="utf-8"))
|
|
for name in ("profile", "experience", "skills", "posts")
|
|
}
|
|
fixtures["/api/auth/session"] = {"role": "admin"}
|
|
fixtures.update({f"/api/posts/{post['slug']}": post for post in fixtures["/api/posts"]})
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(StaticApp, directory=str(dist)))
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
base_url = f"http://127.0.0.1:{server.server_port}"
|
|
try:
|
|
with sync_playwright() as playwright:
|
|
browser = playwright.chromium.launch(channel="msedge", headless=True)
|
|
context = browser.new_context(base_url=base_url, color_scheme="light")
|
|
context.route("**/api/**", lambda route: route.fulfill(json=fixtures.get(urlsplit(route.request.url).path, {})))
|
|
context.route("https://**", lambda route: route.abort())
|
|
page = context.new_page()
|
|
errors = []
|
|
page.on("pageerror", lambda error: errors.append(str(error)))
|
|
page.goto("/")
|
|
expect(page.locator("html")).to_have_attribute("data-theme", "light")
|
|
page.emulate_media(color_scheme="dark")
|
|
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
|
|
page.get_by_role("button", name="Switch to light mode").focus()
|
|
page.keyboard.press("Enter")
|
|
expect(page.locator("html")).to_have_attribute("data-theme", "light")
|
|
page.reload()
|
|
expect(page.locator("html")).to_have_attribute("data-theme", "light")
|
|
other = context.new_page()
|
|
other.goto("/")
|
|
page.get_by_role("button", name="Switch to dark mode").click()
|
|
expect(other.locator("html")).to_have_attribute("data-theme", "dark")
|
|
other.close()
|
|
|
|
for path in ("/", "/experience", "/skills", "/blog", f"/blog/{fixtures['/api/posts'][0]['slug']}", "/contact"):
|
|
page.goto(path)
|
|
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
|
|
expect(page.locator(".theme-toggle")).to_be_visible()
|
|
|
|
for width in (1280, 901, 800, 721, 720, 600, 390, 320):
|
|
page.set_viewport_size({"width": width, "height": 900})
|
|
page.goto("/")
|
|
expect(page.locator(".theme-toggle")).to_be_visible()
|
|
brand = page.locator(".brand").bounding_box()
|
|
nav = page.locator(".nav-shell" if width > 720 else ".menu-button").bounding_box()
|
|
toggle = page.locator(".theme-toggle").bounding_box()
|
|
account = page.locator(".header-signin").bounding_box()
|
|
assert brand["x"] + brand["width"] <= nav["x"] + 1, width
|
|
assert nav["x"] + nav["width"] <= toggle["x"] + 1, width
|
|
assert toggle["x"] + toggle["width"] <= account["x"] + 1, width
|
|
assert account["x"] + account["width"] <= width, width
|
|
if width <= 720:
|
|
page.get_by_role("button", name="Open navigation").click()
|
|
page.get_by_role("navigation").get_by_role("link", name="Skills", exact=True).click()
|
|
expect(page.locator(".nav-shell")).not_to_be_visible()
|
|
|
|
page.evaluate('sessionStorage.setItem("alex-journal-admin", `${Math.floor(Date.now() / 1000) + 3600}.isolated-test-token`)')
|
|
page.reload()
|
|
page.get_by_role("button", name="Admin account").click()
|
|
expect(page.get_by_role("button", name="Sign out", exact=True)).to_be_visible()
|
|
assert page.locator("dialog").evaluate("e => getComputedStyle(e).backgroundColor") == "rgb(20, 29, 25)"
|
|
page.get_by_role("button", name="Sign out", exact=True).click()
|
|
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
|
|
assert page.evaluate('localStorage.getItem("portfolio-theme")') == "dark"
|
|
|
|
# Browser privacy restrictions must not break the toggle.
|
|
restricted = browser.new_context(base_url=base_url, color_scheme="dark")
|
|
restricted.route("**/api/**", lambda route: route.fulfill(json=fixtures.get(urlsplit(route.request.url).path, {})))
|
|
restricted.route("https://**", lambda route: route.abort())
|
|
restricted.add_init_script('Object.defineProperty(window, "localStorage", {get() { throw new DOMException("Blocked", "SecurityError"); }});')
|
|
blocked = restricted.new_page()
|
|
blocked.on("pageerror", lambda error: errors.append(str(error)))
|
|
blocked.goto("/")
|
|
expect(blocked.locator("html")).to_have_attribute("data-theme", "dark")
|
|
blocked.get_by_role("button", name="Switch to light mode").click()
|
|
expect(blocked.locator("html")).to_have_attribute("data-theme", "light")
|
|
assert not errors, errors
|
|
browser.close()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
print("PASS: system preference, keyboard toggle, persistence, tab sync, six public routes, eight header widths, admin sign-out, and blocked storage.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|