blob: 1499c4fe028e3e42806e3af85e6f3bf0a103148f [file]
# Copyright 2026 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Header tests for pigweed.dev."""
import unittest
from helpers import (
DocsServer,
get_chromium_executable,
get_docs_dir,
get_html_files,
)
from playwright.sync_api import sync_playwright
class HeaderTest(unittest.TestCase):
"""Header tests."""
@classmethod
def setUpClass(cls):
cls.docs_dir = get_docs_dir()
cls.html_files = get_html_files(cls.docs_dir)
cls.server = DocsServer(cls.docs_dir)
cls.server.start()
@classmethod
def tearDownClass(cls):
cls.server.stop()
def test_header_present(self):
"""Verifies that <pw-header id="pw-header"> is present on every HTML
page."""
self.assertGreater(
len(self.html_files),
0,
f"No HTML files found under {self.docs_dir}",
)
missing_header: list[str] = []
for html_file in self.html_files:
rel_path = html_file.relative_to(self.docs_dir)
content = html_file.read_text(encoding="utf-8", errors="ignore")
if '<pw-header id="pw-header">' not in content:
missing_header.append(str(rel_path))
self.assertEqual(
len(missing_header),
0,
f"Found {len(missing_header)} pages missing "
f'<pw-header id="pw-header">:\n' + "\n".join(missing_header[:20]),
)
def test_header_nav_present(self):
"""Verifies that the top navigation bar with links is present."""
index_file = self.docs_dir / "index.html"
self.assertTrue(index_file.exists(), f"{index_file} does not exist")
content = index_file.read_text(encoding="utf-8")
self.assertIn('<nav id="pw-header-nav"', content)
self.assertIn('class="pw-nav-item"', content)
self.assertIn('class="pw-nav-popover"', content)
def test_header_menu_present(self):
"""Verifies that the mobile hamburger menu button is present."""
index_file = self.docs_dir / "index.html"
self.assertTrue(index_file.exists(), f"{index_file} does not exist")
content = index_file.read_text(encoding="utf-8")
self.assertIn('id="pw-header-menu"', content)
def test_mobile_menu_navigation(self):
"""Verifies mobile menu toggle and links across Sphinx subpage,
Doxygen, and Rustdoc."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
# 1. Sphinx subpage (pw_string/docs.html)
page.goto(
self.server.url_for("pw_string/docs.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
# Click to open Sphinx drawer
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
sidebar = page.locator(".bd-sidebar-primary")
sidebar.wait_for(state="visible", timeout=5000)
nav_links = sidebar.locator("a")
self.assertGreater(nav_links.count(), 0)
# Click backdrop to close
backdrop = page.locator("#pw-nav-backdrop")
backdrop.click(force=True)
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
# 2. Doxygen page (api/cc/group__pw__string.html)
page.goto(
self.server.url_for("api/cc/group__pw__string.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
doxygen_nav = page.locator("#side-nav")
doxygen_nav.wait_for(state="visible", timeout=5000)
self.assertGreater(doxygen_nav.locator("a").count(), 0)
# 3. Rustdoc page (rustdoc/pw_status/index.html)
page.goto(
self.server.url_for("rustdoc/pw_status/index.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
rustdoc_sidebar = page.locator("nav.sidebar")
rustdoc_sidebar.wait_for(state="visible", timeout=5000)
self.assertGreater(rustdoc_sidebar.locator("a").count(), 0)
browser.close()
def test_reference_page_mobile_nav(self):
"""Verifies that the reference landing page (api/index.html) mobile
drawer is not blank and renders the full site navigation."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
page.goto(
self.server.url_for("api/index.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
# Click hamburger button to open drawer
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
sidebar = page.locator(".bd-sidebar-primary")
sidebar.wait_for(state="visible", timeout=5000)
# Check that pw-site-nav is visible and contains navigation links
site_nav = sidebar.locator(".pw-site-nav")
site_nav.wait_for(state="visible", timeout=5000)
nav_links = site_nav.locator("a")
self.assertGreater(nav_links.count(), 0)
# Verify active link for Reference page
active_link = site_nav.locator("a[aria-current='page']")
active_link.wait_for(state="visible", timeout=5000)
self.assertIn("Reference", active_link.inner_text())
# Click backdrop to close drawer
backdrop = page.locator("#pw-nav-backdrop")
backdrop.click(force=True)
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
browser.close()
def test_doxygen_mobile_nav_press_and_scroll(self):
"""Verifies Doxygen navigation drawer on mobile supports clicking
tree links and scrolling."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
page.goto(
self.server.url_for("api/cc/group__pw__string.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
menu_btn.click()
side_nav = page.locator("#side-nav")
side_nav.wait_for(state="visible", timeout=5000)
# Check that nav tree links are clickable and not intercepted by backdrop
first_link = side_nav.locator("#nav-tree a").first
first_link.wait_for(state="visible", timeout=5000)
first_link.click()
# Verify side-nav is scrollable
is_scrollable = page.evaluate(
"() => { const el = document.querySelector('#side-nav'); return el.scrollHeight > el.clientHeight; }"
)
self.assertTrue(
is_scrollable,
"#side-nav should have scrollable content on mobile",
)
browser.close()
def test_rustdoc_topbar_title_alignment(self):
"""Verifies that the API item title in rustdoc-topbar on mobile is
left-aligned rather than centered."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
page.goto(
self.server.url_for("rustdoc/pw_status/enum.Error.html"),
wait_until="domcontentloaded",
)
topbar = page.locator("rustdoc-topbar")
topbar.wait_for(state="visible", timeout=5000)
h2 = topbar.locator("h2")
h2.wait_for(state="visible", timeout=5000)
box = h2.bounding_box()
self.assertIsNotNone(box)
# h2 should be aligned near the left edge (< 50px), not centered (~150-180px)
self.assertLess(
box["x"],
50,
f"Rustdoc topbar title is too far right (x={box['x']})",
)
browser.close()
def test_rustdoc_desktop_layout(self):
"""Verifies that rustdoc-topbar is hidden on desktop viewports and
sidebar sits as a column to the left of the main content."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 1280, "height": 800}
)
page = context.new_page()
page.goto(
self.server.url_for("rustdoc/pw_status/enum.Error.html"),
wait_until="domcontentloaded",
)
# rustdoc-topbar must be hidden on desktop
topbar = page.locator("rustdoc-topbar")
self.assertFalse(
topbar.is_visible(),
"rustdoc-topbar should be hidden on desktop viewports",
)
# sidebar should be visible and located at left edge
sidebar = page.locator("nav.sidebar")
sidebar.wait_for(state="visible", timeout=5000)
sidebar_box = sidebar.bounding_box()
self.assertIsNotNone(sidebar_box)
self.assertEqual(sidebar_box["x"], 0)
# main content should be located to the right of the sidebar
main = page.locator("main")
main.wait_for(state="visible", timeout=5000)
main_box = main.bounding_box()
self.assertIsNotNone(main_box)
self.assertGreaterEqual(main_box["x"], sidebar_box["width"])
browser.close()
def test_sphinx_page_toc_fab(self):
"""Verifies floating action button for Sphinx page TOC on mobile."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
# 1. Page with TOC (pw_string/docs.html)
page.goto(
self.server.url_for("pw_string/docs.html"),
wait_until="domcontentloaded",
)
fab = page.locator("#pw-toc-fab")
fab.wait_for(state="visible", timeout=5000)
self.assertEqual(fab.get_attribute("aria-expanded"), "false")
# Click FAB to open TOC drawer
fab.click()
self.assertEqual(fab.get_attribute("aria-expanded"), "true")
toc_sidebar = page.locator(".bd-sidebar-secondary")
toc_sidebar.wait_for(state="visible", timeout=5000)
# Initial scroll position should be at the top of the page
self.assertEqual(page.evaluate("window.scrollY"), 0)
# Click TOC link to close drawer and scroll to target section
toc_link = toc_sidebar.locator("a[href^='#']").first
target_href = toc_link.get_attribute("href")
self.assertIsNotNone(target_href)
target_id = target_href.lstrip("#")
toc_link.click()
self.assertEqual(fab.get_attribute("aria-expanded"), "false")
# Verify page scrolled down to the target section
page.wait_for_timeout(300)
scroll_y = page.evaluate("window.scrollY")
self.assertGreater(
scroll_y,
0,
f"Page did not scroll after clicking TOC link ({target_href}), got scrollY={scroll_y}",
)
# Verify target section element is visible in the viewport
target_box = page.locator(f"#{target_id}").bounding_box()
self.assertIsNotNone(target_box)
self.assertGreaterEqual(target_box["y"], 0)
self.assertLess(target_box["y"], 667)
# Re-open and verify backdrop click closes TOC drawer
fab.click()
self.assertEqual(fab.get_attribute("aria-expanded"), "true")
backdrop = page.locator("#pw-nav-backdrop")
backdrop.click(force=True)
self.assertEqual(fab.get_attribute("aria-expanded"), "false")
# 2. Doxygen page -> FAB should not be visible
page.goto(
self.server.url_for("api/cc/group__pw__string.html"),
wait_until="domcontentloaded",
)
fab = page.locator("#pw-toc-fab")
self.assertFalse(
fab.is_visible(),
"FAB should not be visible on Doxygen pages",
)
# 3. Rustdoc page -> FAB should not be visible
page.goto(
self.server.url_for("rustdoc/pw_status/index.html"),
wait_until="domcontentloaded",
)
fab = page.locator("#pw-toc-fab")
self.assertFalse(
fab.is_visible(),
"FAB should not be visible on Rustdoc pages",
)
browser.close()
def test_mobile_drawers_mutual_exclusion_and_escape(self):
"""Verifies mutual exclusivity between primary nav and TOC drawers,
and verifies Escape key closes active drawers."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
page.goto(
self.server.url_for("pw_string/docs.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
fab = page.locator("#pw-toc-fab")
menu_btn.wait_for(state="visible", timeout=5000)
fab.wait_for(state="visible", timeout=5000)
# Open primary nav
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
self.assertEqual(fab.get_attribute("aria-expanded"), "false")
# Opening TOC drawer closes primary nav
fab.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
self.assertEqual(fab.get_attribute("aria-expanded"), "true")
# Press Escape to close TOC drawer
page.keyboard.press("Escape")
self.assertEqual(fab.get_attribute("aria-expanded"), "false")
# Open primary nav and press Escape to close
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
page.keyboard.press("Escape")
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
browser.close()
def test_desktop_header_layout(self):
"""Verifies that #pw-header-nav, #pw-header-search, and #pw-header-tools
are grouped on the right side of the header on desktop."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 1440, "height": 900}
)
page = context.new_page()
page.goto(
self.server.url_for("index.html"),
wait_until="domcontentloaded",
)
brand = page.locator("#pw-header-brand")
nav = page.locator("#pw-header-nav")
search = page.locator("#pw-header-search")
tools = page.locator("#pw-header-tools")
brand_box = brand.bounding_box()
nav_box = nav.bounding_box()
search_box = search.bounding_box()
tools_box = tools.bounding_box()
self.assertIsNotNone(brand_box)
self.assertIsNotNone(nav_box)
self.assertIsNotNone(search_box)
self.assertIsNotNone(tools_box)
# Check left-to-right order: brand -> nav -> search -> tools
self.assertLessEqual(
brand_box["x"] + brand_box["width"], nav_box["x"]
)
self.assertLess(nav_box["x"] + nav_box["width"], search_box["x"])
self.assertLess(
search_box["x"] + search_box["width"], tools_box["x"]
)
# Verify that brand is on the far left and tools is on the far right
self.assertLess(brand_box["x"], 100)
self.assertGreater(tools_box["x"] + tools_box["width"], 1440 - 150)
# Verify that search sits directly adjacent to tools, and nav directly adjacent to search
self.assertLessEqual(
tools_box["x"] - (search_box["x"] + search_box["width"]), 24
)
self.assertLessEqual(
search_box["x"] - (nav_box["x"] + nav_box["width"]), 24
)
browser.close()
def test_mobile_header_layout(self):
"""Verifies that hamburger menu and brand are on the left, search and
tools are on the right, with a gap in the middle on mobile."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
page.goto(
self.server.url_for("index.html"),
wait_until="domcontentloaded",
)
menu = page.locator("#pw-header-menu")
brand = page.locator("#pw-header-brand")
tools = page.locator("#pw-header-tools")
search_mobile = page.locator("#pw-search-mobile")
theme = page.locator("pw-theme")
menu_box = menu.bounding_box()
brand_box = brand.bounding_box()
tools_box = tools.bounding_box()
search_box = search_mobile.bounding_box()
theme_box = theme.bounding_box()
self.assertIsNotNone(menu_box)
self.assertIsNotNone(brand_box)
self.assertIsNotNone(tools_box)
self.assertIsNotNone(search_box)
self.assertIsNotNone(theme_box)
# Left side: menu and brand close to each other on the left
self.assertLess(menu_box["x"], 30)
self.assertLess(menu_box["x"] + menu_box["width"], brand_box["x"])
menu_brand_gap = brand_box["x"] - (
menu_box["x"] + menu_box["width"]
)
self.assertLess(menu_brand_gap, 25)
# Right side: search and theme close to each other on the right
self.assertGreater(tools_box["x"] + tools_box["width"], 375 - 40)
self.assertLess(
search_box["x"] + search_box["width"], theme_box["x"]
)
tools_gap = theme_box["x"] - (search_box["x"] + search_box["width"])
self.assertLessEqual(tools_gap, 16)
# Largest gap in the middle between brand and right-side tools
middle_gap = tools_box["x"] - (brand_box["x"] + brand_box["width"])
self.assertGreater(middle_gap, menu_brand_gap)
self.assertGreater(middle_gap, tools_gap)
self.assertGreater(middle_gap, 40)
browser.close()
def test_sphinx_sitewide_nav_expanded_and_scrolled_on_mobile(self):
"""Verifies that on mobile, the hamburger menu opens the sitewide
navigation on Sphinx pages, expands ancestors for nested pages, and
scrolls to the active item."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
# 1. Test deeply nested page on mobile
page.goto(
self.server.url_for("pw_allocator/backends.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
menu_btn.click()
sidebar = page.locator("#pst-primary-sidebar")
sidebar.wait_for(state="visible", timeout=5000)
# Section nav should be hidden on mobile, sitewide nav visible
mobile_section_nav = sidebar.locator(
"nav[aria-label='Section Navigation']"
)
mobile_site_nav = sidebar.locator(
"nav[aria-label='Site Navigation']"
)
self.assertFalse(mobile_section_nav.is_visible())
self.assertTrue(mobile_site_nav.is_visible())
# Check that the sitewide nav title is present
title = mobile_site_nav.locator(".bd-links__title")
title.wait_for(state="visible", timeout=5000)
self.assertEqual(title.inner_text(), "Site Navigation")
# Check that ancestor details are open
allocator_item = mobile_site_nav.locator(
"li:has(> a[href*='pw_allocator/docs.html'])"
)
allocator_details = allocator_item.locator("> details")
self.assertTrue(
allocator_details.evaluate("el => el.hasAttribute('open')"),
"pw_allocator details should be expanded with open attribute",
)
# Check that active page has aria-current="page" and is visible
active_link = mobile_site_nav.locator("a[aria-current='page']")
active_link.wait_for(state="visible", timeout=5000)
self.assertIn("Backends", active_link.inner_text())
# 3. Test scrolling down on a page far down the sitewide navigation
page.goto(
self.server.url_for("pw_string/docs.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
menu_btn.click()
sidebar = page.locator("#pst-primary-sidebar")
sidebar.wait_for(state="visible", timeout=5000)
string_active = sidebar.locator(
".pw-site-nav a[aria-current='page']"
)
string_active.wait_for(state="visible", timeout=5000)
page.wait_for_timeout(300)
sidebar_scroll_top = sidebar.evaluate("el => el.scrollTop")
self.assertGreater(
sidebar_scroll_top,
0,
f"Sidebar should have scrolled down to pw_string, got scrollTop={sidebar_scroll_top}",
)
# 4. Test homepage mobile sitewide nav
page.goto(
self.server.url_for("index.html"),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
menu_btn.click()
sidebar = page.locator("#pst-primary-sidebar")
sidebar.wait_for(state="visible", timeout=5000)
title = sidebar.locator(".bd-links__title")
title.wait_for(state="visible", timeout=5000)
self.assertEqual(title.inner_text(), "Site Navigation")
context.close()
browser.close()
def test_rustdoc_mobile_nav_in_page_link_closes_menu(self):
"""Verifies that clicking an in-page anchor link (e.g. RefUnwindSafe)
in the Rustdoc sidebar on mobile closes the navigation drawer and
restores the hamburger menu icon."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 375, "height": 667}
)
page = context.new_page()
page.goto(
self.server.url_for(
"rustdoc/pw_log_backend_api/enum.LogLevel.html"
),
wait_until="domcontentloaded",
)
menu_btn = page.locator("#pw-header-menu")
menu_btn.wait_for(state="visible", timeout=5000)
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
icon = menu_btn.locator(".material-symbols-outlined")
self.assertEqual(icon.inner_text().strip(), "menu")
# Click hamburger menu to open nav
menu_btn.click()
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "true")
self.assertEqual(icon.inner_text().strip(), "menu_open")
sidebar = page.locator("nav.sidebar")
sidebar.wait_for(state="visible", timeout=5000)
# Click RefUnwindSafe link in the sidebar
link = sidebar.locator('a:has-text("RefUnwindSafe")').first
link.wait_for(state="visible", timeout=5000)
link.click()
# Verify hamburger menu has returned to original closed icon
self.assertEqual(menu_btn.get_attribute("aria-expanded"), "false")
self.assertEqual(icon.inner_text().strip(), "menu")
browser.close()
def test_universal_skip_link(self):
"""Verifies universal skip link presence and behavior across Sphinx,
Rustdoc, and Doxygen."""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
pages_to_test = [
("pw_bytes/docs.html", "Sphinx", "#main-content"),
("rustdoc/pigweed/index.html", "Rustdoc", "#main-content"),
("api/cc/group__pw__bytes.html", "Doxygen", "#doc-content"),
("api/index.html", "Reference", "#main-content"),
]
for path, name, expected_target in pages_to_test:
context = browser.new_context(
viewport={"width": 1280, "height": 800}
)
page = context.new_page()
page.goto(
self.server.url_for(path),
wait_until="domcontentloaded",
)
# Verify #pw-skip-link exists and targets the engine's content container
skip_link = page.locator("#pw-skip-link")
self.assertEqual(
skip_link.count(),
1,
f"#pw-skip-link should exist on {name}",
)
self.assertEqual(
skip_link.get_attribute("href"),
expected_target,
f"#pw-skip-link href on {name} should be {expected_target}",
)
self.assertEqual(
skip_link.inner_text().strip(), "Skip to main content"
)
# Verify native skip links are suppressed
pst_skip = page.locator("#pst-skip-link")
if pst_skip.count() > 0:
self.assertFalse(
pst_skip.is_visible(),
f"#pst-skip-link should be hidden on {name}",
)
rust_skip = page.locator(".skip-main-content")
if rust_skip.count() > 0:
self.assertFalse(
rust_skip.is_visible(),
f".skip-main-content should be hidden on {name}",
)
# Verify skip link target container exists
target_locator = page.locator(expected_target)
self.assertTrue(
target_locator.count() > 0,
f"{expected_target} target container should exist on {name}",
)
# Tabbing initially from top of document focuses #pw-skip-link
page.keyboard.press("Tab")
focused_id = page.evaluate("document.activeElement.id")
self.assertEqual(
focused_id,
"pw-skip-link",
f"First focused element on {name} should be #pw-skip-link, got {focused_id}",
)
# Activating the skip link moves focus to the target content container
page.keyboard.press("Enter")
page.wait_for_timeout(100)
if name == "Rustdoc":
# Verify #main-content does not have a yellow target background
bg_color = page.evaluate(
"() => window.getComputedStyle(document.querySelector('#main-content')).backgroundColor"
)
self.assertIn(
bg_color,
("rgba(0, 0, 0, 0)", "transparent"),
f"Rustdoc main content should not have a target highlight background, got {bg_color}",
)
# Pressing Tab after skip link should focus inside content, not #pw-header
page.keyboard.press("Tab")
is_inside_header = page.evaluate(
"() => document.querySelector('#pw-header').contains(document.activeElement)"
)
self.assertFalse(
is_inside_header,
f"After using skip link on {name}, Tab should focus content, not header",
)
context.close()
browser.close()
def test_nav_popover_escape_dismiss(self):
"""Verifies that dropdown popovers opened via keyboard focus can be
closed with Escape and that subsequent Tab moves to the next header item.
"""
chromium_bin = get_chromium_executable()
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=chromium_bin,
headless=True,
)
context = browser.new_context(
viewport={"width": 1280, "height": 800}
)
page = context.new_page()
page.goto(
self.server.url_for("pw_bytes/docs.html"),
wait_until="domcontentloaded",
)
# Find nav item with dropdown popover
item = page.locator(".pw-nav-item:has(.pw-nav-popover)").first
trigger = item.locator(".pw-nav-link.has-children")
popover = item.locator(".pw-nav-popover")
trigger.focus()
page.wait_for_timeout(100)
self.assertEqual(trigger.get_attribute("aria-expanded"), "true")
self.assertTrue(popover.is_visible())
# Press Escape to dismiss popover
page.keyboard.press("Escape")
page.wait_for_timeout(100)
self.assertEqual(trigger.get_attribute("aria-expanded"), "false")
self.assertFalse(popover.is_visible())
# Focus should remain on trigger
is_trigger_focused = page.evaluate(
"() => document.activeElement === document.querySelector('.pw-nav-item:has(.pw-nav-popover) .pw-nav-link.has-children')"
)
self.assertTrue(
is_trigger_focused,
"Focus should return to trigger link after pressing Escape",
)
# Tab should skip all popover links and advance to next nav item
page.keyboard.press("Tab")
is_inside_popover = page.evaluate(
"() => document.querySelector('.pw-nav-popover').contains(document.activeElement)"
)
self.assertFalse(
is_inside_popover,
"Next focused element should not be inside the dismissed popover",
)
browser.close()
if __name__ == "__main__":
unittest.main()