| # 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. |
| """Search UI tests for pigweed.dev.""" |
| |
| import unittest |
| |
| from helpers import ( |
| DocsServer, |
| get_chromium_executable, |
| get_docs_dir, |
| ) |
| from playwright.sync_api import sync_playwright |
| |
| |
| class SearchTest(unittest.TestCase): |
| """Search UI tests.""" |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.docs_dir = get_docs_dir() |
| cls.server = DocsServer(cls.docs_dir) |
| cls.server.start() |
| |
| @classmethod |
| def tearDownClass(cls): |
| cls.server.stop() |
| |
| def _run_search_test(self, viewport: dict[str, int], trigger_selector: str): |
| representative_pages = [ |
| # Sphinx overview page |
| "overview.html", |
| # Sphinx module subpage |
| "pw_string/docs.html", |
| # Doxygen group page |
| "api/cc/group__pw__string.html", |
| # Doxygen class page |
| "api/cc/classpw_1_1_status.html", |
| # Rustdoc module index |
| "rustdoc/pw_status/index.html", |
| # Rustdoc enum page |
| "rustdoc/pw_status/enum.Error.html", |
| ] |
| |
| 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=viewport) |
| page = context.new_page() |
| |
| for rel_path in representative_pages: |
| url = self.server.url_for(rel_path) |
| page.goto(url, wait_until="networkidle") |
| |
| # Open Pagefind search modal |
| search_btn = page.locator(trigger_selector).first |
| search_btn.wait_for(state="attached", timeout=5000) |
| search_btn.click(force=True) |
| |
| # Locate search input inside the modal and submit query |
| modal = page.locator("pagefind-modal") |
| search_input = modal.locator("input").first |
| search_input.wait_for(state="visible", timeout=10000) |
| search_input.fill("pw_status") |
| |
| # Wait for search result fragment to load and link to be visible |
| result_link = modal.locator( |
| "a.pf-result-link, a.pf-heading-link" |
| ).first |
| result_link.wait_for(state="visible", timeout=20000) |
| |
| # Get target href of the result link before clicking |
| expected_target = result_link.get_attribute("href") |
| self.assertIsNotNone(expected_target) |
| |
| # Click the search result and verify navigation to expected page |
| result_link.click() |
| page.wait_for_load_state("domcontentloaded") |
| |
| self.assertTrue( |
| page.url.endswith(expected_target.lstrip("./")) |
| or page.url == expected_target, |
| f"Searching from {rel_path} expected navigation to " |
| f"{expected_target}, got {page.url}", |
| ) |
| |
| browser.close() |
| |
| def test_search(self): |
| """Verifies searching via Pagefind modal on desktop viewport.""" |
| self._run_search_test( |
| viewport={"width": 1280, "height": 800}, |
| trigger_selector=( |
| "#pw-search-desktop button, .pf-trigger-btn, #pw-search-desktop" |
| ), |
| ) |
| |
| def test_search_mobile(self): |
| """Verifies searching via Pagefind modal on mobile viewport.""" |
| self._run_search_test( |
| viewport={"width": 375, "height": 667}, |
| trigger_selector="#pw-search-mobile", |
| ) |
| |
| def test_legacy_searchbox_hidden(self): |
| """Verifies that legacy Sphinx #searchbox element is always hidden.""" |
| chromium_bin = get_chromium_executable() |
| |
| with sync_playwright() as p: |
| browser = p.chromium.launch( |
| executable_path=chromium_bin, |
| headless=True, |
| ) |
| for width in [375, 1024, 1440]: |
| context = browser.new_context( |
| viewport={"width": width, "height": 800} |
| ) |
| page = context.new_page() |
| page.goto( |
| self.server.url_for("index.html"), |
| wait_until="domcontentloaded", |
| ) |
| |
| searchbox = page.locator("#searchbox") |
| if searchbox.count() > 0: |
| self.assertFalse( |
| searchbox.first.is_visible(), |
| f"#searchbox should be hidden at viewport width {width}", |
| ) |
| context.close() |
| |
| browser.close() |
| |
| def _get_first_search_excerpt(self, query: str) -> str: |
| 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("index.html"), |
| wait_until="networkidle", |
| ) |
| |
| # Open Pagefind search modal |
| search_btn = page.locator( |
| "#pw-search-desktop button, .pf-trigger-btn, #pw-search-desktop" |
| ).first |
| search_btn.wait_for(state="attached", timeout=5000) |
| search_btn.click(force=True) |
| |
| # Locate search input inside the modal and submit query |
| modal = page.locator("pagefind-modal") |
| search_input = modal.locator("input").first |
| search_input.wait_for(state="visible", timeout=10000) |
| search_input.fill(query) |
| |
| # Wait for search result fragment to load |
| result_link = modal.locator( |
| "a.pf-result-link, a.pf-heading-link" |
| ).first |
| result_link.wait_for(state="visible", timeout=20000) |
| |
| # Retrieve the first result excerpt |
| excerpt = modal.locator(".pf-result-excerpt").first |
| excerpt.wait_for(state="visible", timeout=20000) |
| excerpt_text = excerpt.inner_text() |
| |
| browser.close() |
| return excerpt_text |
| |
| def test_search_excerpt_excludes_skip_link(self): |
| """Verifies that the first search result excerpt for |
| 'Crate pw_log_backend_api' does not include 'Skip to main content'.""" |
| excerpt_text = self._get_first_search_excerpt( |
| "Crate pw_log_backend_api" |
| ) |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Skip to main content", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Skip to main content', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_header(self): |
| """Verifies that the first search result excerpt for 'Crate pw_base64' |
| excludes 'light_mode', ensuring pw-header is excluded from indexing.""" |
| excerpt_text = self._get_first_search_excerpt("Crate pw_base64") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "light_mode", |
| excerpt_text, |
| f"First search result excerpt should not contain 'light_mode', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_breadcrumbs(self): |
| """Verifies that the first search result excerpt for 'Crate pw_base64' |
| excludes 'Home', ensuring breadcrumbs UI is excluded from indexing.""" |
| excerpt_text = self._get_first_search_excerpt("Crate pw_base64") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Home", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Home', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_rustdoc_source(self): |
| """Verifies that the first search result excerpt for 'Crate pw_status' |
| excludes 'Source', ensuring rustdoc source link UI is excluded.""" |
| excerpt_text = self._get_first_search_excerpt("Crate pw_status") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Source", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Source', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_rustdoc_expand_description(self): |
| """Verifies that the first search result excerpt for 'Crate pw_status' |
| excludes 'Expand description', ensuring rustdoc hideme button is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt("Crate pw_status") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Expand description", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Expand description', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_toc_fab(self): |
| """Verifies that search result excerpts for 'pw_async2' |
| exclude 'toc', ensuring #pw-toc-fab button is excluded.""" |
| excerpt_text = self._get_first_search_excerpt("pw_async2") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "toc", |
| excerpt_text, |
| f"First search result excerpt should not contain 'toc', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_collapse_sidebar(self): |
| """Verifies that search result excerpts for 'pw_async2' |
| exclude 'Collapse Sidebar', ensuring .pst-collapse-sidebar-label is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt("pw_async2") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Collapse Sidebar", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Collapse Sidebar', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_expand_sidebar(self): |
| """Verifies that search result excerpts for 'pw_async2' |
| exclude 'Expand Sidebar', ensuring .pst-expand-sidebar-label is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt("pw_async2") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Expand Sidebar", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Expand Sidebar', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_back_to_top(self): |
| """Verifies that search result excerpts for 'pw_async2' |
| exclude 'Back to top', ensuring #pst-back-to-top button is excluded.""" |
| excerpt_text = self._get_first_search_excerpt("pw_async2") |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Back to top", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Back to top', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_doxygen_footer(self): |
| """Verifies that search result excerpts for 'Heap allocate space for tasks' |
| exclude 'Generated by', ensuring Doxygen .footer is excluded.""" |
| excerpt_text = self._get_first_search_excerpt( |
| "Heap allocate space for tasks" |
| ) |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Generated by", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Generated by', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_doxygen_navrow1(self): |
| """Verifies that search result excerpts for 'Heap allocate space for tasks' |
| exclude 'Modules', ensuring Doxygen site navigation #navrow1 is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt( |
| "Heap allocate space for tasks" |
| ) |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Modules", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Modules', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_doxygen_loading(self): |
| """Verifies that search result excerpts for 'Heap allocate space for tasks' |
| exclude 'Loading...', ensuring Doxygen #Loading status UI is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt( |
| "Heap allocate space for tasks" |
| ) |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Loading...", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Loading...', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_doxygen_searching(self): |
| """Verifies that search result excerpts for 'EpollDispatcher Member List' |
| exclude 'Searching...', ensuring Doxygen #Searching status UI is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt( |
| "EpollDispatcher Member List" |
| ) |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "Searching...", |
| excerpt_text, |
| f"First search result excerpt should not contain 'Searching...', got: {excerpt_text}", |
| ) |
| |
| def test_search_excerpt_excludes_doxygen_no_matches(self): |
| """Verifies that search result excerpts for 'EpollDispatcher Member List' |
| exclude 'No Matches', ensuring Doxygen #NoMatches status UI is excluded. |
| """ |
| excerpt_text = self._get_first_search_excerpt( |
| "EpollDispatcher Member List" |
| ) |
| self.assertTrue( |
| excerpt_text, |
| "Expected search result excerpt to be non-empty", |
| ) |
| self.assertNotIn( |
| "No Matches", |
| excerpt_text, |
| f"First search result excerpt should not contain 'No Matches', got: {excerpt_text}", |
| ) |
| |
| def test_search_results_exclude_rustdoc_source_links(self): |
| """Verifies that searching for 'pw_time' returns results and none of the |
| .pf-result-link[href] values match the pattern */rustdoc/src/*.""" |
| 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("index.html"), |
| wait_until="networkidle", |
| ) |
| |
| # Open Pagefind search modal |
| search_btn = page.locator( |
| "#pw-search-desktop button, .pf-trigger-btn, #pw-search-desktop" |
| ).first |
| search_btn.wait_for(state="attached", timeout=5000) |
| search_btn.click(force=True) |
| |
| # Locate search input inside the modal and submit query |
| modal = page.locator("pagefind-modal") |
| search_input = modal.locator("input").first |
| search_input.wait_for(state="visible", timeout=10000) |
| search_input.fill("pw_time") |
| |
| # Wait for search result links to appear |
| result_links = modal.locator(".pf-result-link") |
| result_links.first.wait_for(state="visible", timeout=20000) |
| |
| hrefs = [link.get_attribute("href") for link in result_links.all()] |
| self.assertGreater( |
| len(hrefs), |
| 0, |
| "Expected at least one .pf-result-link for query 'pw_time'", |
| ) |
| |
| for href in hrefs: |
| self.assertIsNotNone(href) |
| self.assertNotIn( |
| "/rustdoc/src/", |
| href, |
| f"Search result link href should not match */rustdoc/src/*, got: {href}", |
| ) |
| |
| browser.close() |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |