Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ jobs:

- name: Build site
# Zola fails the build on broken internal links.
run: zola build
# This artifact is a test preview; production has its own deploy build.
run: zola build --base-url http://127.0.0.1:8879

- name: Verify editor, demo outputs and publication downloads
run: python3 -m unittest discover -s scripts -p 'test_*.py' -v
Expand All @@ -59,6 +60,53 @@ jobs:
path: public/
retention-days: 7

navigation:
name: Navigation (${{ matrix.browser }})
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: scripts/requirements-browser.txt

- name: Download test preview
uses: actions/download-artifact@v4
with:
name: site-public
path: public

- name: Install browser test tools
run: |
python -m pip install -r scripts/requirements-browser.txt
python -m playwright install --with-deps ${{ matrix.browser }}

- name: Verify responsive navigation and keyboard interactions
run: |
python -m http.server 8879 --bind 127.0.0.1 --directory public > /tmp/navigation-server.log 2>&1 &
server_pid=$!
trap 'kill "$server_pid"' EXIT
curl --fail --silent --output /dev/null --retry 10 --retry-connrefused --retry-delay 1 --retry-max-time 20 http://127.0.0.1:8879/
python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser ${{ matrix.browser }} --artifacts navigation-results

- name: Upload navigation evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: navigation-${{ matrix.browser }}
path: navigation-results/
retention-days: 7

release-links:
name: Check release channel links
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ release_version = "1.21.19"
navbar_items = [
{ code = "en", nav_items = [
{ url = "$BASE_URL/", name = "Home" },
{ url = "$BASE_URL/editor/", name = "Try the editor" },
{ url = "$BASE_URL/editor/", name = "Editor" },
{ url = "$BASE_URL/capabilities", name = "Capabilities" },
{ url = "$BASE_URL/how-tos", name = "How-tos" },
{ url = "https://docs.terraphim.ai", name = "Docs" },
Expand Down
46 changes: 46 additions & 0 deletions scripts/README-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Responsive navigation checks

These checks exercise the generated site in Chromium, Firefox and WebKit on the
homepage and synonym-search tutorial at 15 widths from 320 to 2,560 CSS pixels,
with web fonts loaded and with font downloads blocked.

Use **Zola 0.22.1**, matching CI. Newer Zola versions may require a separate
template migration.

```sh
python3 -m venv /tmp/terraphim-navigation-venv
/tmp/terraphim-navigation-venv/bin/python -m pip install -r scripts/requirements-browser.txt
/tmp/terraphim-navigation-venv/bin/python -m playwright install chromium firefox webkit
zola build --base-url http://127.0.0.1:8879
python3 -m http.server 8879 --bind 127.0.0.1 --directory public
```

In another terminal, run each engine:

```sh
/tmp/terraphim-navigation-venv/bin/python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser chromium --artifacts /tmp/navigation-chromium
/tmp/terraphim-navigation-venv/bin/python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser firefox --artifacts /tmp/navigation-firefox
/tmp/terraphim-navigation-venv/bin/python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser webkit --artifacts /tmp/navigation-webkit
```

The build base URL must match the server. Otherwise absolute asset URLs could
load production CSS instead of the patch. The checker rejects a stylesheet from
another origin and records the served stylesheet SHA-256 in `navigation.json`.
It also checks exact navigation destinations, including their origins.

Checks cover single-line desktop text, visible non-overlapping controls,
compact/desktop selection, preserved links and actual keyboard actions: search
input focus, Escape to close search, opening and scrolling the compact menu to
Donate, and navigating to Editor. The open compact menu is resized to desktop
and back. WebKit uses Option+Tab to include links in keyboard traversal on macOS.

Runs exit nonzero on failure and save JSON plus representative screenshots.
The normal-font run requires Space Grotesk to load; a font CDN failure is not
silently relabelled as success. A separate fallback run blocks font downloads.
CI runs all three engines and uploads evidence even when a check fails.
Its `site-public` artifact is a localhost test build; production deployment
builds separately using the production base URL.

To verify a deployment, use `--base-url https://terraphim.ai`. Manual acceptance
also includes actual 200% browser zoom: viewport/device-scale overrides are not
browser zoom, and Playwright WebKit is not native Safari.
234 changes: 234 additions & 0 deletions scripts/check-navigation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Check real rendered navigation against a built site, preview or production."""

import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import sys
from urllib.parse import urljoin, urlparse

from playwright.sync_api import sync_playwright


WIDTHS = (320, 390, 768, 899, 900, 1024, 1200, 1279, 1280, 1366, 1440,
1799, 1800, 1920, 2560)
PAGES = ("/", "/how-tos/synonym-search/")
DESTINATIONS = (
("Home", "/"), ("Editor", "/editor/"),
("Capabilities", "/capabilities"), ("How-tos", "/how-tos"),
("Docs", "https://docs.terraphim.ai/"), ("Rust", "https://terraphim.rs/"),
("Releases", "/releases"), ("Install", "/docs/installation"),
("Community", "/#community"), ("Donate", "/docs/donate"),
)

GEOMETRY = """() => {
const rect = el => {
const r = el.getBoundingClientRect();
return {x:r.x, y:r.y, right:r.right, bottom:r.bottom, w:r.width, h:r.height};
};
const shown = el => !!el && getComputedStyle(el).display !== 'none'
&& el.getBoundingClientRect().width > 0;
const desktop = shown(document.querySelector('.topbar'));
const mobile = shown(document.querySelector('.mobile-nav'));
const selector = desktop
? '.topbar-brand, .topbar-link, .topbar-status, .topbar-search'
: '.mobile-nav-brand, .nav-search-btn, .nav-burger';
const controls = [...document.querySelectorAll(selector)].map(el => {
const range = document.createRange(); range.selectNodeContents(el);
const textRects = el.matches('.topbar-link') ? [...range.getClientRects()]
.filter(r => r.width > 0).map(r => ({x:r.x,y:r.y,right:r.right,bottom:r.bottom})) : [];
const r = rect(el);
const hit = document.elementFromPoint((r.x+r.right)/2, (r.y+r.bottom)/2);
return {text:el.textContent.trim(), rect:r, textRects,
lines: new Set(textRects.map(r => Math.round(r.y))).size,
hit:!!hit && (hit === el || el.contains(hit))};
});
const links = selector => [...document.querySelectorAll(selector)]
.map(el => ({text:el.textContent.trim(), href:el.href}));
return {width:innerWidth, clientWidth:document.documentElement.clientWidth,
desktop, mobile, controls, desktopLinks:links('.topbar-link'),
mobileLinks:links('.nav-mobile-menu a'),
version:document.querySelector('.status-label').textContent.trim(),
stylesheet:[...document.styleSheets].map(s => s.href).find(h => h && h.includes('/css/warp.css')),
fonts:[...document.fonts].map(f => ({family:f.family,status:f.status}))};
}"""


def geometry_errors(data, base_url):
"""Assert observable layout rather than CSS declarations."""
errors = []
if data["desktop"] != (data["width"] >= 1280) or data["mobile"] == data["desktop"]:
errors.append("Wrong header mode or multiple visible headers")
previous = None
for control in data["controls"]:
r = control["rect"]
name = control["text"] or "icon control"
if r["x"] < -0.6 or r["right"] > data["clientWidth"] + 0.6:
errors.append(f"{name} extends beyond the viewport")
if r["y"] < -0.6 or r["bottom"] > 52.6:
errors.append(f"{name} extends beyond the header height")
if previous is not None and r["x"] < previous - 0.6:
errors.append(f"{name} overlaps the previous control")
previous = r["right"]
if control["lines"] > 1:
errors.append(f"{name} wraps onto {control['lines']} lines")
for text in control["textRects"]:
if text["y"] < 0 or text["bottom"] > 52:
errors.append(f"{name} text is vertically clipped")
if not control["hit"]:
errors.append(f"{name} is obscured or offscreen")
if not data["version"].startswith("Clients v"):
errors.append("Client release badge missing")
for kind in ("desktopLinks", "mobileLinks"):
actual = [(link["text"], link["href"]) for link in data[kind]]
expected = [(label, urljoin(base_url, destination)) for label, destination in DESTINATIONS]
if actual != expected:
errors.append(f"{kind} changed destinations or labels: {actual}")
return errors


def settle(page):
page.evaluate("() => document.fonts.ready")
page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")


def tab_to(page, selector):
"""Reach a control with actual Tab presses, including WebKit's full tab mode."""
for _ in range(50):
if page.evaluate("s => document.activeElement.matches(s)", selector):
return
mac_webkit = sys.platform == "darwin" and page.context.browser.browser_type.name == "webkit"
page.keyboard.press("Alt+Tab" if mac_webkit else "Tab")
raise AssertionError(f"Keyboard cannot reach {selector}")


def interactions(page, url):
"""Check actual controls and keyboard navigation on the rendered page."""
outcomes = []
for width in (1024, 1280):
page.set_viewport_size({"width": width, "height": 480})
page.goto(url, wait_until="load")
settle(page)
desktop = width >= 1280
search = "#nav-search-ribbon" if desktop else "#nav-search"
tab_to(page, search)
page.keyboard.press("Enter")
page.locator("#search-modal.is-active").wait_for(state="visible")
assert page.locator("#search").evaluate("el => el === document.activeElement")
page.keyboard.press("Escape")
page.locator("#search-modal").wait_for(state="hidden")
outcomes.append({"width": width, "searchKeyboardAndFocus": True})

# Reload to start a new keyboard traversal at the document start.
page.goto(url, wait_until="load")
if not desktop:
tab_to(page, ".nav-burger")
page.keyboard.press("Enter")
assert page.locator(".nav-burger").get_attribute("aria-expanded") == "true"
menu = page.locator(".nav-mobile-menu")
menu.wait_for(state="visible")
assert menu.locator("a").count() == 10
tab_to(page, '.nav-mobile-menu a[href$="/docs/donate"]')
last = menu.locator("a").last
visible = last.evaluate("""el => {
const r=el.getBoundingClientRect(), m=el.parentElement.getBoundingClientRect();
const hit=document.elementFromPoint((r.left+r.right)/2,(r.top+r.bottom)/2);
return r.top >= m.top && r.bottom <= Math.min(m.bottom,innerHeight)+1
&& (hit===el || el.contains(hit));
}""")
assert visible, "Last compact-menu destination is not visible after keyboard scroll"
page.keyboard.press("Enter")
page.wait_for_url(lambda url: urlparse(url).path.rstrip("/") == "/docs/donate",
wait_until="domcontentloaded")
outcomes.append({"width": width, "lastDestinationKeyboardNavigation": page.url})
page.goto(url, wait_until="load")
tab_to(page, ".nav-burger")
page.keyboard.press("Enter")
page.set_viewport_size({"width": 1280, "height": 480})
settle(page)
assert not page.locator(".nav-mobile-menu").is_visible()
assert page.locator(".topbar").is_visible()
page.set_viewport_size({"width": 1024, "height": 480})
settle(page)
assert page.locator(".nav-mobile-menu").is_visible()
assert not page.locator(".topbar").is_visible()
outcomes.append({"width": width, "resizeRetainsOpenState": True})
selector = '.topbar-link[href$="/editor/"]' if desktop else '.nav-mobile-menu a[href$="/editor/"]'
tab_to(page, selector)
page.keyboard.press("Enter")
page.wait_for_url("**/editor/", wait_until="domcontentloaded")
outcomes.append({"width": width, "editorKeyboardNavigation": page.url})
return outcomes


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", required=True)
parser.add_argument("--browser", choices=("chromium", "firefox", "webkit"), required=True)
parser.add_argument("--artifacts", type=Path, required=True)
args = parser.parse_args()
args.artifacts.mkdir(parents=True, exist_ok=True)
report = {"checkedAt": datetime.now(timezone.utc).isoformat(), "baseUrl": args.base_url,
"browser": args.browser, "geometry": [], "interactions": [], "errors": []}
try:
with sync_playwright() as playwright:
browser = getattr(playwright, args.browser).launch()
report["browserVersion"] = browser.version
for fallback in (False, True):
context = browser.new_context(viewport={"width": 1280, "height": 800},
reduced_motion="reduce")
if fallback:
context.route("**/*", lambda route: route.abort() if
route.request.resource_type == "font" or
urlparse(route.request.url).netloc in ("fonts.googleapis.com", "fonts.gstatic.com")
else route.continue_())
page = context.new_page()
page.set_default_timeout(15000)
for index, path in enumerate(PAGES):
url = urljoin(args.base_url.rstrip("/") + "/", path.lstrip("/"))
page.goto(url, wait_until="load")
settle(page)
if index == 0 and not fallback:
css_url = urljoin(args.base_url.rstrip("/") + "/", "css/warp.css")
css = page.request.get(css_url)
assert css.ok, f"Stylesheet returned HTTP {css.status}"
report["stylesheet"] = {"url": css_url,
"sha256": hashlib.sha256(css.body()).hexdigest()}
for width in WIDTHS:
page.set_viewport_size({"width": width, "height": 800})
settle(page)
data = page.evaluate(GEOMETRY)
errors = geometry_errors(data, args.base_url)
if data["stylesheet"] != urljoin(args.base_url.rstrip("/") + "/", "css/warp.css"):
errors.append("Rendered stylesheet is not from the site under test")
loaded = any("Space Grotesk" in f["family"] and f["status"] == "loaded"
for f in data["fonts"])
if not fallback and not loaded:
errors.append("Normal-font run did not load Space Grotesk")
data.update({"path": path, "fallbackFonts": fallback, "errors": errors})
report["geometry"].append(data)
prefix = f"page-{index}-{'fallback' if fallback else 'normal'}-{width}"
report["errors"].extend(f"{prefix}: {e}" for e in errors)
if width in (1024, 1280, 2560) and index == 0:
page.screenshot(path=str(args.artifacts / f"{prefix}.png"))
if not fallback:
try:
report["interactions"].append({"path": path, "outcomes": interactions(page, url)})
except Exception as error:
report["errors"].append(f"{path} interactions: {error}")
page.screenshot(path=str(args.artifacts / f"page-{index}-interaction-failure.png"))
context.close()
browser.close()
except Exception as error:
report["errors"].append(f"Browser check could not complete: {error}")
report["passed"] = not report["errors"] and len(report["geometry"]) == len(WIDTHS) * len(PAGES) * 2
(args.artifacts / "navigation.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps({"browser": args.browser, "passed": report["passed"],
"geometryCases": len(report["geometry"]), "errors": report["errors"][:12]}))
return 0 if report["passed"] else 1


if __name__ == "__main__":
sys.exit(main())
1 change: 1 addition & 0 deletions scripts/requirements-browser.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
playwright==1.63.0
Loading
Loading