| # 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. |
| """Theme 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 ThemeTest(unittest.TestCase): |
| """Theme 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 test_theme_switch_and_persistence(self): |
| """Verifies theme switching and persistence 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, |
| ) |
| context = browser.new_context( |
| viewport={"width": 1280, "height": 800} |
| ) |
| page = context.new_page() |
| |
| def verify_theme(expected_theme: str): |
| page.wait_for_function( |
| "() => document.documentElement.getAttribute('data-theme')" |
| f" === '{expected_theme}'", |
| timeout=5000, |
| ) |
| theme_val = page.evaluate( |
| "document.documentElement.getAttribute('data-theme')" |
| ) |
| self.assertEqual(theme_val, expected_theme) |
| btn = page.locator( |
| f"pw-theme button[data-theme-val='{expected_theme}']" |
| ) |
| btn.wait_for(state="visible", timeout=5000) |
| self.assertIn("active", btn.get_attribute("class") or "") |
| |
| # 1. Start on a Sphinx page |
| page.goto( |
| self.server.url_for("pw_bytes/docs.html"), |
| wait_until="domcontentloaded", |
| ) |
| |
| # 2. Set theme to dark and verify switch |
| dark_btn = page.locator("pw-theme button[data-theme-val='dark']") |
| dark_btn.wait_for(state="visible", timeout=5000) |
| dark_btn.click() |
| verify_theme("dark") |
| |
| # 3. Navigate to Rustdoc page and verify dark theme persists |
| page.goto( |
| self.server.url_for("rustdoc/pw_bytes/index.html"), |
| wait_until="domcontentloaded", |
| ) |
| verify_theme("dark") |
| |
| # 4. Navigate to Doxygen page and verify dark theme persists |
| page.goto( |
| self.server.url_for("api/cc/group__pw__bytes.html"), |
| wait_until="domcontentloaded", |
| ) |
| verify_theme("dark") |
| |
| # 5. Set theme to light and verify switch |
| light_btn = page.locator("pw-theme button[data-theme-val='light']") |
| light_btn.wait_for(state="visible", timeout=5000) |
| light_btn.click() |
| verify_theme("light") |
| |
| # 6. Navigate to a different Doxygen page and verify light theme |
| # persists |
| page.goto( |
| self.server.url_for("api/cc/group__pw__kvs.html"), |
| wait_until="domcontentloaded", |
| ) |
| verify_theme("light") |
| |
| # 7. Navigate to a different Sphinx page and verify light theme |
| # persists |
| page.goto( |
| self.server.url_for("pw_allocator/docs.html"), |
| wait_until="domcontentloaded", |
| ) |
| verify_theme("light") |
| |
| browser.close() |
| |
| def test_rustdoc_ayu_theme_option_hidden(self): |
| """Verifies that the ayu theme option is hidden in Rustdoc settings.""" |
| 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/settings.html"), |
| wait_until="domcontentloaded", |
| ) |
| |
| # Wait for settings to load |
| page.wait_for_selector('label[for="theme-dark"]', timeout=5000) |
| self.assertTrue( |
| page.locator('label[for="theme-dark"]').is_visible() |
| ) |
| self.assertTrue( |
| page.locator('label[for="theme-light"]').is_visible() |
| ) |
| |
| # Verify label[for="theme-ayu"] is not shown |
| ayu_label = page.locator('label[for="theme-ayu"]') |
| self.assertFalse( |
| ayu_label.is_visible(), |
| "label[for='theme-ayu'] should be hidden in Rustdoc settings", |
| ) |
| |
| browser.close() |
| |
| def test_os_color_scheme_preference(self): |
| """Verifies that PwTheme respects OS-level prefers-color-scheme when no |
| explicit localStorage theme has been set.""" |
| chromium_bin = get_chromium_executable() |
| |
| with sync_playwright() as p: |
| browser = p.chromium.launch( |
| executable_path=chromium_bin, |
| headless=True, |
| ) |
| |
| # 1. Test OS light mode preference |
| context_light = browser.new_context( |
| viewport={"width": 1280, "height": 800}, |
| color_scheme="light", |
| ) |
| page_light = context_light.new_page() |
| page_light.goto( |
| self.server.url_for("pw_bytes/docs.html"), |
| wait_until="domcontentloaded", |
| ) |
| page_light.wait_for_function( |
| "() => document.documentElement.getAttribute('data-theme')" |
| " === 'light'", |
| timeout=5000, |
| ) |
| self.assertEqual( |
| page_light.evaluate( |
| "document.documentElement.getAttribute('data-theme')" |
| ), |
| "light", |
| "Theme should default to light when prefers-color-scheme is light", |
| ) |
| light_btn = page_light.locator( |
| "pw-theme button[data-theme-val='light']" |
| ) |
| self.assertIn("active", light_btn.get_attribute("class") or "") |
| self.assertEqual(light_btn.get_attribute("aria-checked"), "true") |
| context_light.close() |
| |
| # 2. Test OS dark mode preference |
| context_dark = browser.new_context( |
| viewport={"width": 1280, "height": 800}, |
| color_scheme="dark", |
| ) |
| page_dark = context_dark.new_page() |
| page_dark.goto( |
| self.server.url_for("pw_bytes/docs.html"), |
| wait_until="domcontentloaded", |
| ) |
| page_dark.wait_for_function( |
| "() => document.documentElement.getAttribute('data-theme')" |
| " === 'dark'", |
| timeout=5000, |
| ) |
| self.assertEqual( |
| page_dark.evaluate( |
| "document.documentElement.getAttribute('data-theme')" |
| ), |
| "dark", |
| "Theme should default to dark when prefers-color-scheme is dark", |
| ) |
| dark_btn = page_dark.locator( |
| "pw-theme button[data-theme-val='dark']" |
| ) |
| self.assertIn("active", dark_btn.get_attribute("class") or "") |
| self.assertEqual(dark_btn.get_attribute("aria-checked"), "true") |
| context_dark.close() |
| |
| browser.close() |
| |
| def test_localStorage_security_error_graceful_handling(self): |
| """Verifies that PwTheme handles localStorage throwing a SecurityError |
| without throwing unhandled exceptions or breaking theme initialization. |
| """ |
| 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}, |
| color_scheme="light", |
| ) |
| # Simulate SecurityError on window.localStorage access |
| context.add_init_script( |
| """ |
| Object.defineProperty(window, 'localStorage', { |
| get: function() { |
| throw new DOMException( |
| "The operation is insecure.", |
| "SecurityError" |
| ); |
| } |
| }); |
| """ |
| ) |
| page = context.new_page() |
| |
| # Verify PwTheme on Rustdoc page (which does not use Sphinx scripts) |
| rustdoc_errors = [] |
| page.on("pageerror", lambda err: rustdoc_errors.append(str(err))) |
| |
| page.goto( |
| self.server.url_for("rustdoc/pw_bytes/index.html"), |
| wait_until="domcontentloaded", |
| ) |
| |
| self.assertEqual( |
| rustdoc_errors, |
| [], |
| f"Rustdoc page had uncaught errors: {rustdoc_errors}", |
| ) |
| |
| # Theme should default to system preference (light) |
| page.wait_for_function( |
| "() => document.documentElement.getAttribute('data-theme')" |
| " === 'light'", |
| timeout=5000, |
| ) |
| self.assertEqual( |
| page.evaluate( |
| "document.documentElement.getAttribute('data-theme')" |
| ), |
| "light", |
| ) |
| |
| dark_btn = page.locator("pw-theme button[data-theme-val='dark']") |
| dark_btn.wait_for(state="visible", timeout=5000) |
| dark_btn.click() |
| |
| page.wait_for_function( |
| "() => document.documentElement.getAttribute('data-theme')" |
| " === 'dark'", |
| timeout=5000, |
| ) |
| self.assertEqual( |
| page.evaluate( |
| "document.documentElement.getAttribute('data-theme')" |
| ), |
| "dark", |
| ) |
| |
| is_bc_defined = page.evaluate( |
| "() => customElements.get('pw-breadcrumbs') !== undefined" |
| ) |
| self.assertTrue( |
| is_bc_defined, |
| "pw-breadcrumbs custom element should be successfully defined", |
| ) |
| |
| browser.close() |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |