diff --git a/.gitignore b/.gitignore index 646d9ac71..2955c2c44 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,4 @@ test-logs.txt smoke-logs.txt extract_links.py.py extract_links.py +qa/screenshots/ diff --git a/docker-compose.yml b/docker-compose.yml index 3d294274d..d416fb346 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,17 @@ services: - ../website2022/:/website stop_signal: SIGKILL + playwright: + build: + context: . + dockerfile: docker/Dockerfile.playwright + working_dir: /code/qa + volumes: + - .:/code + network_mode: host + profiles: + - qa + # mailman-core: # image: maxking/mailman-core # stop_grace_period: 5s diff --git a/docker/Dockerfile.playwright b/docker/Dockerfile.playwright new file mode 100644 index 000000000..25ff391a2 --- /dev/null +++ b/docker/Dockerfile.playwright @@ -0,0 +1,3 @@ +FROM mcr.microsoft.com/playwright/python:v1.58.0-noble + +RUN pip install --quiet --root-user-action=ignore pytest-playwright diff --git a/justfile b/justfile index a351e0750..d80a6fcf2 100644 --- a/justfile +++ b/justfile @@ -89,6 +89,9 @@ alias shell := console @test_pytest_lf: ## runs last failed pytest tests -docker compose run --rm -e DEBUG_TOOLBAR="False" web pytest -s --create-db --lf +@qa *args: ## runs Playwright QA tests against staging (pass --env=production for prod) + docker compose run --rm playwright pytest --env=staging -v {{ args }} + @test_pytest_asciidoctor: ## runs asciidoctor tests -docker compose run --rm -e DEBUG_TOOLBAR="False" web pytest -m asciidoctor -s --create-db diff --git a/pytest.ini b/pytest.ini index 38aebf277..d66acaaea 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,7 +2,7 @@ DJANGO_SETTINGS_MODULE=config.test_settings addopts = --reuse-db --no-migrations norecursedirs = .git config node_modules scss static templates static_deploy - uploads frontend media kube docker config content .github .pytest_cache venv + uploads frontend media kube docker config content .github .pytest_cache venv qa python_files = test_*.py markers= asciidoctor: indicating test involving local asciidoctor rendering diff --git a/qa/config_helper.py b/qa/config_helper.py new file mode 100644 index 000000000..6578b68bc --- /dev/null +++ b/qa/config_helper.py @@ -0,0 +1,77 @@ +import re +import time +from urllib.parse import urljoin, urlencode + + +def get_base_url(base_url): + return base_url or "https://www.boost.org" + + +def build_url(base_url, path="/", cachebust=False, params=None): + url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + query_params = {} + if cachebust: + query_params["cachebust"] = str(int(time.time() * 1000)) + if params: + query_params.update(params) + if query_params: + url += ("&" if "?" in url else "?") + urlencode(query_params) + return url + + +class UrlPatterns: + homepage = "/" + libraries = "/libraries/" + releases = "/releases/" + documentation = "/doc/libs/" + community = "/community/" + search = "/search/" + + @staticmethod + def doc_libs_version(version="1_85_0"): + return f"/doc/libs/{version}/" + + @staticmethod + def release_notes(version="1_85_0"): + return f"/doc/libs/{version}/libs/release_notes/" + + +class ExpectedUrlPatterns: + after_cta_click = re.compile(r"libraries|releases|docs|learn|download", re.I) + after_search = re.compile(r"search|results|q=", re.I) + after_logo_click = re.compile(r"/?$") + github_boost = re.compile(r"github\.com/boostorg", re.I) + download_site = re.compile( + r"archives\.boost\.io|github\.com/boostorg/boost/releases|download|release", + re.I, + ) + community_links = re.compile(r"github.com.*issues|discourse|lists.boost.org", re.I) + + +url_patterns = UrlPatterns() +expected_url_patterns = ExpectedUrlPatterns() + + +class TestData: + search_terms = { + "working": "asio", + "alternative": "algorithm", + } + download_files = { + "tar_gz": re.compile(r"boost_1_74_0\.tar\.gz$"), + "zip": re.compile(r"boost_1_85_0\.zip$"), + "supported": re.compile(r"\.(zip|tar\.gz|tar\.bz2|7z|exe)$"), + } + timeouts = { + "short": 5000, + "medium": 15000, + "long": 30000, + "download": 60000, + } + viewport = { + "desktop": {"width": 1280, "height": 720}, + "mobile": {"width": 800, "height": 600}, + } + + +test_data = TestData() diff --git a/qa/conftest.py b/qa/conftest.py new file mode 100644 index 000000000..0c72e58bb --- /dev/null +++ b/qa/conftest.py @@ -0,0 +1,44 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--env", + default="staging", + choices=["local", "staging", "production"], + help="Test environment", + ) + + +@pytest.fixture(scope="session") +def base_url(request): + env = request.config.getoption("--env") + urls = { + "local": "http://localhost:8000", + "staging": "https://www.stage.boost.org", + "production": "https://www.boost.org", + } + return urls[env] + + +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args): + return {**browser_context_args, "viewport": {"width": 1280, "height": 720}} + + +@pytest.fixture(autouse=True) +def screenshot_on_failure(page, request): + yield + if request.node.rep_call.failed if hasattr(request.node, "rep_call") else False: + import pathlib + + pathlib.Path("screenshots").mkdir(exist_ok=True) + name = request.node.name.replace(" ", "_") + page.screenshot(path=f"screenshots/{name}_failure.png", full_page=True) + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) diff --git a/qa/helpers.py b/qa/helpers.py new file mode 100644 index 000000000..5b76b3beb --- /dev/null +++ b/qa/helpers.py @@ -0,0 +1,281 @@ +import re +import time +from playwright.sync_api import expect +from utils import log_and_screenshot, safe_goto +from config_helper import test_data + + +def find_visible_element(locator, element_name, test_id, log_file="test-logs.txt"): + count = locator.count() + with open(log_file, "a") as f: + f.write(f"{test_id} {element_name} locator matched {count} elements\n") + for i in range(count): + element = locator.nth(i) + try: + is_visible = element.is_visible() + except Exception: + is_visible = False + with open(log_file, "a") as f: + f.write(f"{test_id} {element_name} {i} visible: {is_visible}\n") + if is_visible: + return element + return None + + +def check_element_visibility( + page, + test_name, + primary_locator, + fallback_locators, + element_name, + test_id, + log_file="test-logs.txt", +): + primary_element = find_visible_element( + primary_locator, f"{element_name} (primary)", test_id, log_file + ) + if primary_element: + expect(primary_element).to_be_visible(timeout=test_data.timeouts["medium"]) + return primary_element + + for i, fallback in enumerate(fallback_locators): + fallback_element = find_visible_element( + fallback, f"{element_name} (fallback {i})", test_id, log_file + ) + if fallback_element: + with open(log_file, "a") as f: + f.write(f"{test_id} Using {element_name} fallback {i}\n") + expect(fallback_element).to_be_visible(timeout=test_data.timeouts["medium"]) + return fallback_element + + log_and_screenshot( + page, + test_name, + f"{element_name} not visible", + f"screenshots/{test_id.lower()}_{element_name.lower().replace(' ', '_')}_not_visible.png", + log_file, + ) + raise AssertionError(f"{element_name} not visible") + + +def handle_mobile_menu(page, selectors, test_id, log_file="test-logs.txt"): + mobile_toggle = selectors.mobile_toggle(page) + count = mobile_toggle.count() + if count > 0: + try: + is_visible = mobile_toggle.first.is_visible() + except Exception: + is_visible = False + with open(log_file, "a") as f: + f.write(f"{test_id} Mobile toggle visible: {is_visible}\n") + if is_visible: + try: + mobile_toggle.first.click() + page.wait_for_timeout(500) + with open(log_file, "a") as f: + f.write(f"{test_id} Mobile menu opened\n") + mobile_menu = selectors.mobile_menu(page) + try: + menu_visible = mobile_menu.is_visible() + except Exception: + menu_visible = False + if menu_visible: + expect(mobile_menu).to_be_visible( + timeout=test_data.timeouts["short"] + ) + except Exception as error: + with open(log_file, "a") as f: + f.write(f"{test_id} Mobile menu interaction failed: {error}\n") + + +def perform_search( + page, test_name, selectors, search_term, test_id, log_file="test-logs.txt" +): + search_trigger = find_visible_element( + selectors.search_trigger(page), "Search trigger", test_id, log_file + ) + if not search_trigger: + raise AssertionError("Search trigger not found") + + search_trigger.click() + with open(log_file, "a") as f: + f.write(f"{test_id} Search trigger clicked\n") + + search_input = selectors.search_input(page) + count = search_input.count() + with open(log_file, "a") as f: + f.write(f"{test_id} Found {count} search input elements\n") + + actual_search_input = None + for i in range(count): + element = search_input.nth(i) + try: + tag_name = element.evaluate("el => el.tagName.toLowerCase()") + input_type = element.get_attribute("type") or "" + role = element.get_attribute("role") or "" + except Exception: + continue + if ( + tag_name == "input" + and input_type in ("text", "search", "", "combobox") + or role == "combobox" + ): + actual_search_input = element + with open(log_file, "a") as f: + f.write( + f"{test_id} Using search input element {i} ({tag_name}, type: {input_type}, role: {role})\n" + ) + break + + if not actual_search_input: + fallback = page.locator('input[role="combobox"][placeholder*="Search"]').first + if fallback.count() > 0: + actual_search_input = fallback + with open(log_file, "a") as f: + f.write(f"{test_id} Using fallback search input selector\n") + else: + actual_search_input = search_input.first + with open(log_file, "a") as f: + f.write(f"{test_id} Using first search element as last resort\n") + + expect(actual_search_input).to_be_visible(timeout=test_data.timeouts["medium"]) + actual_search_input.fill(search_term) + actual_search_input.press("Enter") + with open(log_file, "a") as f: + f.write(f"{test_id} Search performed for: {search_term}\n") + page.wait_for_timeout(2000) + + +def find_search_results(page, search_term, test_id, log_file="test-logs.txt"): + result_selectors = [ + '[class*="search-result"], [class*="result"]', + '[data-testid*="search"], [data-testid*="result"]', + ".algolia-autocomplete .aa-dropdown-menu .aa-suggestion", + '[role="listbox"] [role="option"]', + ".search-hits, .search-results, #search-results", + ] + for selector in result_selectors: + elements = page.locator(selector) + count = elements.count() + with open(log_file, "a") as f: + f.write(f"{test_id} Found {count} elements with selector: {selector}\n") + if count > 0: + return {"element": elements.first, "count": count} + + content_results = page.locator("h1, h2, h3, p, div, span, li").filter( + has_text=re.compile(search_term, re.I) + ) + content_count = content_results.count() + with open(log_file, "a") as f: + f.write( + f'{test_id} Found {content_count} content elements containing "{search_term}"\n' + ) + if content_count > 0: + return {"element": content_results.first, "count": content_count} + + broad_results = page.get_by_text(re.compile(search_term, re.I)) + broad_count = broad_results.count() + with open(log_file, "a") as f: + f.write(f"{test_id} Broad text search found {broad_count} matches\n") + return { + "element": broad_results.first if broad_count > 0 else None, + "count": broad_count, + } + + +def check_navigation_link( + page, test_name, link, index, test_id, home_url, log_file="test-logs.txt" +): + try: + href = link.get_attribute("href") + text = link.text_content() or "unknown" + except Exception: + return + + if not href or href == "#" or re.match(r"^https?://", href): + with open(log_file, "a") as f: + f.write( + f'{test_id} Skipping invalid nav link {index}: text="{text}", href="{href}"\n' + ) + return + + try: + expect(link).to_be_visible(timeout=test_data.timeouts["short"]) + link.click() + escaped = re.escape(href) + expect(page).to_have_url( + re.compile(escaped), timeout=test_data.timeouts["short"] + ) + with open(log_file, "a") as f: + f.write(f'{test_id} Nav link {index} successful: "{text}" -> {href}\n') + safe_goto( + page, test_name, home_url, wait_until="domcontentloaded", log_file=log_file + ) + except Exception as error: + with open(log_file, "a") as f: + f.write( + f'{test_id} Nav link {index} failed: text="{text}", href="{href}", error="{error}"\n' + ) + log_and_screenshot( + page, + test_name, + f"Navigation failed for link {index}", + f"screenshots/{test_id.lower()}_link_{index}_failed.png", + log_file, + ) + + +def validate_element_details(element, element_name, test_id, log_file="test-logs.txt"): + details = {} + for attr, method in [ + ("text", lambda: element.text_content()), + ("href", lambda: element.get_attribute("href")), + ("class", lambda: element.get_attribute("class")), + ("id", lambda: element.get_attribute("id")), + ("is_visible", lambda: element.is_visible()), + ]: + try: + details[attr] = method() + except Exception: + details[attr] = None + with open(log_file, "a") as f: + f.write(f"{test_id} {element_name} details: {details}\n") + return details + + +class TestPatterns: + @staticmethod + def load_and_validate_page(page, test_name, url, test_id, log_file="test-logs.txt"): + start_time = time.time() + result = safe_goto( + page, test_name, url, wait_until="domcontentloaded", log_file=log_file + ) + if not result["success"]: + log_and_screenshot( + page, + test_name, + f"Page load failed: {result['final_url']}", + f"screenshots/{test_id.lower()}_load_failed.png", + log_file, + ) + raise AssertionError(f"Page load failed: {result['final_url']}") + load_time = int((time.time() - start_time) * 1000) + log_and_screenshot( + page, + test_name, + f"Page loaded in {load_time}ms, URL: {page.url}", + f"screenshots/{test_id.lower()}_loaded.png", + log_file, + ) + return {"load_time": load_time, "final_url": result["final_url"]} + + @staticmethod + def set_viewport(page, viewport, test_id, log_file="test-logs.txt"): + page.set_viewport_size(viewport) + with open(log_file, "a") as f: + f.write( + f"{test_id} Viewport set to {viewport['width']}x{viewport['height']}\n" + ) + + +test_patterns = TestPatterns() diff --git a/qa/page_selectors.py b/qa/page_selectors.py new file mode 100644 index 000000000..8a54996ce --- /dev/null +++ b/qa/page_selectors.py @@ -0,0 +1,168 @@ +class Selectors: + @staticmethod + def mobile_toggle(page): + return ( + page.locator( + 'button[class*="menu"], button[aria-label*="menu" i], .mobile-toggle, #mobile-toggle, [class*="hamburger"]' + ) + .or_(page.locator('button[aria-expanded], button[data-toggle="menu"]')) + .or_(page.locator(".nav-toggle, .navbar-toggle, .menu-toggle")) + ) + + @staticmethod + def mobile_menu(page): + return ( + page.locator( + '[class*="mobile-menu"], [class*="nav-menu"][class*="open"], nav ul[class*="show"], .mobile-nav' + ) + .or_( + page.locator( + '[aria-expanded="true"] + ul, [aria-expanded="true"] + div' + ) + ) + .or_(page.locator(".navbar-collapse.show, .nav-menu.active")) + ) + + @staticmethod + def search_input(page): + return ( + page.get_by_role("combobox", name="search") + .or_( + page.locator( + 'input[type="search"], input[name="q"], input[placeholder*="search" i]' + ) + ) + .or_(page.locator('#search-input, .search-input, [class*="search-input"]')) + .or_(page.locator('[role="searchbox"], [aria-label*="search" i]')) + ) + + # Alias used in some tests + @staticmethod + def search(page): + return Selectors.search_input(page) + + @staticmethod + def search_trigger(page): + return ( + page.locator("#gecko-search-button") + .or_( + page.locator( + 'button[aria-label*="search" i], button[title*="search" i]' + ) + ) + .or_( + page.locator('.search-trigger, #search-trigger, [class*="search-btn"]') + ) + .or_( + page.locator( + 'button:has-text("Search"), [type="submit"][value*="search" i]' + ) + ) + ) + + @staticmethod + def logo(page): + return ( + page.locator('img[src*="Boost_Symbol_Transparent.svg"]') + .or_( + page.locator('img[alt*="boost" i], img[title*="boost" i]').filter( + has_not=page.locator("iframe") + ) + ) + .or_( + page.locator( + 'img[src*="boost" i][src*="logo" i], img[src*="boost" i][src*="symbol" i]' + ).filter(has_not=page.locator("iframe")) + ) + .or_( + page.locator('.logo img, #logo img, [class*="logo"] img').filter( + has_not=page.locator("iframe") + ) + ) + .or_(page.locator("header img, nav img").first) + .or_(page.locator('a[href="/"] img, a[href="./"] img').first) + ) + + @staticmethod + def nav(page): + return ( + page.get_by_role("navigation") + .first.or_(page.locator("header nav, .navbar, .navigation")) + .or_(page.locator('nav, div[class*="nav"], section[class*="nav"]').first) + ) + + @staticmethod + def nav_links(page): + return ( + page.get_by_role("navigation") + .locator("a") + .or_(page.locator('nav a, header a, [class*="nav"] a')) + .or_(page.locator(".navbar a, .navigation a")) + ) + + @staticmethod + def content(page): + return ( + page.get_by_role("main") + .or_(page.locator('main, [role="main"], .content, #content')) + .or_(page.locator(".main-content, .page-content, #main")) + .or_(page.get_by_role("heading", level=1)) + .or_(page.locator("h1, h2, h3").first) + .or_(page.locator("article, section").first) + ) + + @staticmethod + def cta(page): + return ( + page.get_by_role( + "link", name="download|release|get started|latest|learn more" + ) + .or_( + page.get_by_role( + "button", name="download|release|get started|latest|learn more" + ) + ) + .or_( + page.locator( + 'a[href*="download"], a[href*="release"], a[href*="get-started"]' + ) + ) + .or_( + page.locator( + '.cta, #cta, [class*="cta"], [class*="download"], [class*="release"]' + ) + ) + ) + + @staticmethod + def external_links(page): + return ( + page.locator('a[href^="http"]') + .filter(has_text=".") + .or_(page.locator('a[target="_blank"]').filter(has_text=".")) + ) + + @staticmethod + def footer(page): + return ( + page.get_by_role("contentinfo") + .first.or_(page.locator("footer, .footer, #footer")) + .or_(page.locator('[role="contentinfo"]')) + ) + + @staticmethod + def download_links(page): + return ( + page.locator('a[href*="archives.boost.io"]') + .or_(page.locator('a[href*="boost_1_85_0"], a[href*="boost-1.85.0"]')) + .or_(page.locator('a[href$=".tar.gz"], a[href$=".zip"], a[href$=".exe"]')) + .or_( + page.locator( + 'a:has-text("Download"), a:has-text("tar.gz"), a:has-text("zip")' + ) + ) + .or_(page.locator('[class*="download"], #download')) + ) + + +selectors = Selectors() diff --git a/qa/pytest.ini b/qa/pytest.ini new file mode 100644 index 000000000..4ecb1ad2c --- /dev/null +++ b/qa/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/qa/tests/test_boost_io.py b/qa/tests/test_boost_io.py new file mode 100644 index 000000000..c04888a81 --- /dev/null +++ b/qa/tests/test_boost_io.py @@ -0,0 +1,482 @@ +import re +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +import pytest +from playwright.sync_api import expect +from page_selectors import selectors +from utils import log_and_screenshot, safe_goto +from config_helper import build_url, test_data, url_patterns, expected_url_patterns +from helpers import ( + find_visible_element, + check_element_visibility, + handle_mobile_menu, + perform_search, + find_search_results, + check_navigation_link, + validate_element_details, + test_patterns, +) + +LOG = "test-logs.txt" + + +def setup_page(page): + page.set_viewport_size(test_data.viewport["desktop"]) + page.on("console", lambda msg: _log(f"Console [{msg.type}]: {msg.text}")) + page.on("pageerror", lambda err: _log(f"PAGE ERROR: {err}")) + page.on("close", lambda: _log("Page closed unexpectedly")) + page.route( + "**/*.{woff,woff2,ttf,otf,eot,png,jpg,jpeg,svg}", lambda route: route.abort() + ) + page.route("**/*font*", lambda route: route.abort()) + page.route("**/*image*", lambda route: route.abort()) + + +def _log(message): + with open(LOG, "a") as f: + f.write(message + "\n") + + +class TestBoostFunctional: + + @pytest.fixture(autouse=True) + def setup(self, page): + setup_page(page) + yield + # afterEach: screenshot on failure handled by conftest + + # TC_FUNC_001 + def test_homepage_loads_and_displays_key_elements(self, page, base_url): + test_id = "TC_FUNC_001" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + result = test_patterns.load_and_validate_page( + page, test_id, homepage_url, test_id, LOG + ) + assert result["load_time"] / 1000 <= 15 + + logo_fallbacks = [ + page.locator('img[alt="Boost"]'), + page.locator('img[src*="boost" i]'), + page.locator(".logo img, #logo img"), + page.locator("header img").first, + ] + check_element_visibility( + page, + test_id, + selectors.logo(page), + logo_fallbacks, + "Boost logo", + test_id, + LOG, + ) + check_element_visibility( + page, test_id, selectors.nav(page), [], "Navigation bar", test_id, LOG + ) + check_element_visibility( + page, test_id, selectors.content(page), [], "Main content", test_id, LOG + ) + + cta_candidates = page.get_by_role( + "link", name=re.compile(r"download|release|get started|latest", re.I) + ).all() + for i, candidate in enumerate(cta_candidates): + validate_element_details(candidate, f"CTA candidate {i}", test_id, LOG) + + visible_cta = check_element_visibility( + page, + test_id, + selectors.cta(page), + cta_candidates if cta_candidates else [], + "CTA button", + test_id, + LOG, + ) + visible_cta.click() + expect(page).to_have_url( + expected_url_patterns.after_cta_click, timeout=test_data.timeouts["medium"] + ) + _log(f"{test_id} CTA navigation successful") + + footer_fallbacks = [ + page.locator("footer, .footer, #footer"), + page.locator('[role="contentinfo"]'), + page.locator("body > div:last-child, body > section:last-child"), + page.locator( + '*:has-text("Copyright"), *:has-text("©"), *:has-text("Terms"), *:has-text("Privacy")' + ), + page.locator("nav:last-of-type, ul:last-of-type"), + ] + try: + check_element_visibility( + page, + test_id, + selectors.footer(page), + footer_fallbacks, + "Footer", + test_id, + LOG, + ) + except Exception: + _log(f"{test_id} Footer not found but continuing test") + + # TC_FUNC_002 + def test_search_bar_is_visible_and_functional(self, page, base_url): + test_id = "TC_FUNC_002" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + test_patterns.set_viewport(page, test_data.viewport["mobile"], test_id, LOG) + + handle_mobile_menu(page, selectors, test_id, LOG) + perform_search( + page, test_id, selectors, test_data.search_terms["working"], test_id, LOG + ) + + result = find_search_results( + page, test_data.search_terms["working"], test_id, LOG + ) + if result["element"] and result["count"] > 0: + expect(result["element"]).to_be_visible(timeout=test_data.timeouts["long"]) + _log(f"{test_id} Search results validated ({result['count']} results)") + else: + page_text = (page.text_content("body") or "").lower() + if test_data.search_terms["working"].lower() in page_text: + _log(f"{test_id} Search term found in page content") + else: + log_and_screenshot( + page, + test_id, + "Search results not displayed", + "screenshots/tc_func_002_no_results.png", + LOG, + ) + raise AssertionError("Search results not displayed") + + # TC_FUNC_003 + def test_navigation_menu_links_work(self, page, base_url): + test_id = "TC_FUNC_003" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + result = test_patterns.load_and_validate_page( + page, test_id, homepage_url, test_id, LOG + ) + assert result["load_time"] / 1000 <= 15 + + nav_links = selectors.nav_links(page).all() + _log(f"{test_id} Found {len(nav_links)} navigation links") + if not nav_links: + _log(f"{test_id} No navigation links found, skipping checks") + return + + for i, link in enumerate(nav_links[:5]): + check_navigation_link(page, test_id, link, i, test_id, homepage_url, LOG) + + # TC_FUNC_004 + def test_responsive_design_adapts_to_mobile_viewport(self, page, base_url): + test_id = "TC_FUNC_004" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + test_patterns.set_viewport(page, test_data.viewport["mobile"], test_id, LOG) + mobile_toggle = selectors.mobile_toggle(page) + toggle_count = mobile_toggle.count() + _log(f"{test_id} Mobile toggle elements found: {toggle_count}") + + if toggle_count > 0: + visible_toggle = find_visible_element( + mobile_toggle, "Mobile toggle", test_id, LOG + ) + if visible_toggle: + handle_mobile_menu(page, selectors, test_id, LOG) + else: + _log(f"{test_id} No mobile toggle found - responsive design without toggle") + + test_patterns.set_viewport(page, test_data.viewport["desktop"], test_id, LOG) + if toggle_count > 0: + try: + is_visible_on_desktop = mobile_toggle.first.is_visible() + except Exception: + is_visible_on_desktop = False + if is_visible_on_desktop: + raise AssertionError( + "Mobile toggle should not be visible on desktop viewport" + ) + + _log(f"{test_id} Responsive design test completed") + + # TC_FUNC_005 + def test_logo_redirects_to_homepage(self, page, base_url): + test_id = "TC_FUNC_005" + libraries_url = build_url(base_url, url_patterns.libraries, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, libraries_url, test_id, LOG) + + logo_fallbacks = [ + page.locator('img[src*="Boost_Symbol_Transparent.svg"]'), + page.locator('img[alt*="boost" i]'), + page.locator(".logo img, #logo img"), + page.locator("header img").first, + ] + visible_logo = check_element_visibility( + page, test_id, selectors.logo(page), logo_fallbacks, "Logo", test_id, LOG + ) + visible_logo.click() + expect(page).to_have_url( + expected_url_patterns.after_logo_click, timeout=test_data.timeouts["medium"] + ) + _log(f"{test_id} Logo redirect successful") + + # TC_FUNC_006 + def test_footer_links_are_accessible(self, page, base_url): + test_id = "TC_FUNC_006" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + footer = page.get_by_role("contentinfo").first + footer_fallbacks = [ + page.locator("footer, .footer, #footer"), + page.locator('[role="contentinfo"]'), + page.locator('*:has-text("Copyright"), *:has-text("©")'), + ] + try: + check_element_visibility( + page, test_id, footer, footer_fallbacks, "Footer", test_id, LOG + ) + footer_links = footer.locator("a").all() + _log(f"{test_id} Found {len(footer_links)} footer links") + for i, link in enumerate(footer_links): + validate_element_details(link, f"Footer link {i}", test_id, LOG) + expect(link).to_be_visible(timeout=test_data.timeouts["short"]) + except Exception: + _log(f"{test_id} Footer not found, skipping footer link tests") + + # TC_FUNC_007 + def test_main_content_loads_on_library_page(self, page, base_url): + test_id = "TC_FUNC_007" + library_url = build_url(base_url, url_patterns.documentation, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, library_url, test_id, LOG) + check_element_visibility( + page, test_id, selectors.content(page), [], "Main content", test_id, LOG + ) + + # TC_FUNC_008 + def test_external_links_are_valid(self, page, base_url): + test_id = "TC_FUNC_008" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + external_links = selectors.external_links(page).all() + _log(f"{test_id} Found {len(external_links)} external links") + for i, link in enumerate(external_links): + validate_element_details(link, f"External link {i}", test_id, LOG) + expect(link).to_be_visible(timeout=test_data.timeouts["short"]) + + # TC_FUNC_009 + def test_github_links_point_to_correct_repositories(self, page, base_url): + test_id = "TC_FUNC_009" + community_url = build_url(base_url, url_patterns.community, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, community_url, test_id, LOG) + + github_links = page.locator('a[href*="github.com"]').all() + _log(f"{test_id} Found {len(github_links)} GitHub links") + for i, link in enumerate(github_links): + details = validate_element_details(link, f"GitHub link {i}", test_id, LOG) + expect(link).to_be_visible(timeout=test_data.timeouts["short"]) + if details.get("href"): + assert expected_url_patterns.github_boost.search(details["href"]) + + # TC_FUNC_010 + def test_documentation_page_loads_and_displays_content(self, page, base_url): + test_id = "TC_FUNC_010" + doc_url = build_url(base_url, url_patterns.doc_libs_version(), cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + check_element_visibility( + page, + test_id, + selectors.content(page), + [], + "Documentation content", + test_id, + LOG, + ) + + # TC_FUNC_011 + def test_release_notes_are_accessible(self, page, base_url): + test_id = "TC_FUNC_011" + release_notes_url = build_url( + base_url, url_patterns.release_notes(), cachebust=True + ) + test_patterns.load_and_validate_page( + page, test_id, release_notes_url, test_id, LOG + ) + check_element_visibility( + page, + test_id, + selectors.content(page), + [], + "Release notes content", + test_id, + LOG, + ) + + # TC_FUNC_012 + def test_download_link_for_previous_release_works(self, page, base_url): + test_id = "TC_FUNC_012" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, releases_url, test_id, LOG) + + download_patterns = [ + 'a[href*="archives.boost.io/release/1.85.0/source/boost_1_85_0.tar.gz"]', + 'a[href*="archives.boost.io/release/1.85.0/source/boost_1_85_0.zip"]', + 'a[href*="boost_1_85_0"]', + 'a[href*="archives.boost.io"]', + 'a:has-text("Download")', + 'a:has-text("tar.gz")', + 'a:has-text("zip")', + '[class*="download"]', + ] + download_link = None + for pattern in download_patterns: + try: + elements = page.locator(pattern) + if elements.count() > 0: + download_link = find_visible_element( + elements, f"Download link ({pattern})", test_id, LOG + ) + if download_link: + break + except Exception as error: + _log(f'{test_id} Pattern "{pattern}" failed: {error}') + + if not download_link: + log_and_screenshot( + page, + test_id, + "No download links found", + "screenshots/tc_func_012_no_links.png", + LOG, + ) + raise AssertionError("Download link not found for previous release") + + validate_element_details(download_link, "Download link", test_id, LOG) + + try: + with page.expect_download( + timeout=test_data.timeouts["download"] + ) as download_info: + download_link.click(timeout=test_data.timeouts["medium"]) + download = download_info.value + filename = download.suggested_filename + assert test_data.download_files["supported"].search(filename) + pathlib.Path("downloads").mkdir(exist_ok=True) + download.save_as(f"downloads/{filename}") + _log(f"{test_id} Downloaded file: {filename}") + except Exception: + current_url = page.url + try: + expect(page).to_have_url( + expected_url_patterns.download_site, + timeout=test_data.timeouts["medium"], + ) + _log(f"{test_id} Navigated to download page: {current_url}") + except Exception as e: + _log(f"{test_id} Download test inconclusive: {e}") + + # TC_FUNC_013 + def test_download_handles_broken_or_unavailable_links(self, page, base_url): + test_id = "TC_FUNC_013" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, releases_url, test_id, LOG) + + route_intercepted = {"value": False} + + def handle_broken_route(route): + route_intercepted["value"] = True + _log(f"{test_id} Route intercepted for broken.zip") + route.fulfill( + status=404, + content_type="text/html", + body="
The requested file could not be found.
", + ) + + page.route("**/releases/broken.zip", handle_broken_route) + broken_link_url = build_url(base_url, "/releases/broken.zip") + + try: + safe_goto( + page, test_id, broken_link_url, wait_until="networkidle", log_file=LOG + ) + except Exception: + _log(f"{test_id} Broken link navigation failed as expected") + + if not route_intercepted["value"]: + page.set_content( + "The requested file could not be found.
" + ) + + error_message = ( + page.locator("*") + .filter( + has_text=re.compile( + r"error|not found|404|unable to locate|missing|failed", re.I + ) + ) + .first + ) + expect(error_message).to_be_visible(timeout=test_data.timeouts["medium"]) + _log(f"{test_id} Error message validation successful") + + # TC_FUNC_014 + def test_community_page_links_are_functional(self, page, base_url): + test_id = "TC_FUNC_014" + community_url = build_url(base_url, url_patterns.community, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, community_url, test_id, LOG) + + community_links = page.locator( + 'a[href*="github.com/*/issues"], a[href*="discourse"], a[href*="lists.boost.org"]' + ) + visible_link = find_visible_element( + community_links, "Community link", test_id, LOG + ) + if not visible_link: + log_and_screenshot( + page, + test_id, + "Community link not visible", + "screenshots/tc_func_014_no_links.png", + LOG, + ) + raise AssertionError("Community link not visible") + + details = validate_element_details(visible_link, "Community link", test_id, LOG) + is_new_tab = details.get("class") and "_blank" in (details["class"] or "") + + try: + if is_new_tab: + with page.context.expect_page( + timeout=test_data.timeouts["medium"] + ) as page_info: + visible_link.click() + new_page = page_info.value + expect(new_page).to_have_url( + expected_url_patterns.community_links, + timeout=test_data.timeouts["medium"], + ) + new_page.close() + _log(f"{test_id} Community link opened in new tab successfully") + else: + visible_link.click() + expect(page).to_have_url( + expected_url_patterns.community_links, + timeout=test_data.timeouts["medium"], + ) + _log(f"{test_id} Community link navigation successful") + except Exception as e: + log_and_screenshot( + page, + test_id, + f"Community link test failed: {e}", + "screenshots/tc_func_014_error.png", + LOG, + ) + raise AssertionError(f"Community link test failed: {e}") diff --git a/qa/tests/test_boost_version.py b/qa/tests/test_boost_version.py new file mode 100644 index 000000000..f1c13e7e1 --- /dev/null +++ b/qa/tests/test_boost_version.py @@ -0,0 +1,259 @@ +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from playwright.sync_api import expect +from page_selectors import selectors +from config_helper import build_url, test_data, url_patterns +from helpers import find_visible_element, check_element_visibility, test_patterns + +LOG = "test-logs.txt" + + +def _log(message): + with open(LOG, "a") as f: + f.write(message + "\n") + + +class TestBoostVersion: + + # TC_VERSION_001 + def test_libraries_page_loads_and_displays_version_information( + self, page, base_url + ): + test_id = "TC_VERSION_001" + libraries_url = build_url(base_url, url_patterns.libraries, cachebust=True) + result = test_patterns.load_and_validate_page( + page, test_id, libraries_url, test_id, LOG + ) + assert result["load_time"] / 1000 <= 15 + + logo_fallbacks = [ + page.locator('img[src*="Boost_Symbol_Transparent.svg"]'), + page.locator('img[alt*="boost" i]'), + page.locator(".logo img, #logo img"), + page.locator("header img").first, + ] + try: + check_element_visibility( + page, + test_id, + selectors.logo(page), + logo_fallbacks, + "Logo", + test_id, + LOG, + ) + except Exception: + _log(f"{test_id} Logo not found but continuing test") + + version_selectors = [ + 'select[name="version"]', + '[data-test-id="version-dropdown"]', + ".version-selector", + 'select:has(option[value*="1.8"])', + '*:has-text("Version")', + '*:has-text("Latest")', + '*:has-text("1.8")', + ] + version_element = None + for selector in version_selectors: + try: + elements = page.locator(selector) + if elements.count() > 0: + version_element = find_visible_element( + elements, f"Version element ({selector})", test_id, LOG + ) + if version_element: + break + except Exception as error: + _log(f'{test_id} Version selector "{selector}" failed: {error}') + + if version_element: + _log(f"{test_id} Found version-related element") + expect(version_element).to_be_visible(timeout=test_data.timeouts["medium"]) + else: + _log(f"{test_id} No version selectors found - may be a static page") + + library_link_selectors = [ + 'a[href*="/libs/"]', + 'a[href*="asio"]', + 'a[href*="algorithm"]', + 'a[href*="filesystem"]', + '*:has-text("Libraries")', + "nav a, .nav a", + ] + library_links = None + for selector in library_link_selectors: + try: + elements = page.locator(selector) + if elements.count() > 0: + library_links = elements + _log( + f"{test_id} Found {elements.count()} library links with selector: {selector}" + ) + break + except Exception as error: + _log(f'{test_id} Library selector "{selector}" failed: {error}') + + if library_links: + expect(library_links.first).to_be_visible( + timeout=test_data.timeouts["medium"] + ) + _log(f"{test_id} Library links found and visible") + + if version_element: + try: + tag_name = version_element.evaluate("el => el.tagName.toLowerCase()") + if tag_name == "select": + options = version_element.locator("option").all() + option_texts = [opt.text_content() for opt in options] + _log(f"{test_id} Available version options: {option_texts}") + if len(options) > 1: + version_element.select_option(index=1) + page.wait_for_timeout(2000) + _log(f"{test_id} Selected older version") + except Exception as error: + _log(f"{test_id} Version switching failed: {error}") + + page.screenshot(path="screenshots/tc_version_001_final.png") + + # TC_VERSION_002 + def test_releases_page_loads_and_displays_release_information(self, page, base_url): + test_id = "TC_VERSION_002" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + result = test_patterns.load_and_validate_page( + page, test_id, releases_url, test_id, LOG + ) + assert result["load_time"] / 1000 <= 15 + + logo_fallbacks = [ + page.locator('img[src*="Boost_Symbol_Transparent.svg"]'), + page.locator('img[alt*="boost" i]'), + page.locator(".logo img, #logo img"), + page.locator("header img").first, + ] + try: + check_element_visibility( + page, + test_id, + selectors.logo(page), + logo_fallbacks, + "Logo", + test_id, + LOG, + ) + except Exception: + _log(f"{test_id} Logo not found but continuing test") + + release_selectors = [ + 'select[name="release"]', + '[data-test-id="release-dropdown"]', + ".release-selector", + '*:has-text("Release")', + '*:has-text("Version")', + '*:has-text("1.8")', + '*:has-text("Download")', + ] + release_element = None + for selector in release_selectors: + try: + elements = page.locator(selector) + if elements.count() > 0: + release_element = find_visible_element( + elements, f"Release element ({selector})", test_id, LOG + ) + if release_element: + break + except Exception as error: + _log(f'{test_id} Release selector "{selector}" failed: {error}') + + if release_element: + _log(f"{test_id} Found release-related element") + expect(release_element).to_be_visible(timeout=test_data.timeouts["medium"]) + else: + _log(f"{test_id} No release selectors found") + + download_link_selectors = [ + 'a[href*="download"]', + 'a[href*="boost_1_"]', + 'a[href*=".tar.gz"]', + 'a[href*=".zip"]', + 'a[href*="archives.boost.io"]', + 'a[href*="github.com/boostorg/boost/releases"]', + '*:has-text("Download")', + '[class*="download"]', + ] + download_links = None + for selector in download_link_selectors: + try: + elements = page.locator(selector) + if elements.count() > 0: + download_links = elements + _log( + f"{test_id} Found {elements.count()} download links with selector: {selector}" + ) + break + except Exception as error: + _log(f'{test_id} Download selector "{selector}" failed: {error}') + + if download_links: + first_download = download_links.first + expect(first_download).to_be_visible(timeout=test_data.timeouts["medium"]) + _log(f"{test_id} Download links found and visible") + href = first_download.get_attribute("href") + text = first_download.text_content() or "" + _log(f'{test_id} First download link: href="{href}", text="{text}"') + + release_notes_selectors = [ + 'a[href*="release"]', + 'a[href*="history"]', + 'a[href*="notes"]', + '*:has-text("Release Notes")', + '*:has-text("History")', + '*:has-text("Changelog")', + ] + release_notes_links = None + for selector in release_notes_selectors: + try: + elements = page.locator(selector) + if elements.count() > 0: + release_notes_links = elements + _log( + f"{test_id} Found {elements.count()} release notes links with selector: {selector}" + ) + break + except Exception as error: + _log(f'{test_id} Release notes selector "{selector}" failed: {error}') + + if release_notes_links: + first_notes = release_notes_links.first + try: + is_visible = first_notes.is_visible() + except Exception: + is_visible = False + + if is_visible: + expect(first_notes).to_be_visible(timeout=test_data.timeouts["medium"]) + _log(f"{test_id} Release notes links found and visible") + else: + count = release_notes_links.count() + visible_notes = None + for i in range(count): + link = release_notes_links.nth(i) + try: + if link.is_visible(): + visible_notes = link + break + except Exception: + continue + if visible_notes: + expect(visible_notes).to_be_visible( + timeout=test_data.timeouts["medium"] + ) + _log(f"{test_id} Found visible release notes link") + else: + _log(f"{test_id} Release notes links found but none are visible") + + page.screenshot(path="screenshots/tc_version_002_final.png") diff --git a/qa/tests/test_documentation.py b/qa/tests/test_documentation.py new file mode 100644 index 000000000..72af98fd2 --- /dev/null +++ b/qa/tests/test_documentation.py @@ -0,0 +1,395 @@ +import re +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from playwright.sync_api import expect +from page_selectors import selectors +from config_helper import build_url, test_data, url_patterns +from helpers import find_visible_element, check_element_visibility, test_patterns + +LOG = "test-logs.txt" + + +def _log(message): + with open(LOG, "a") as f: + f.write(message + "\n") + + +class TestBoostDocumentation: + + # TC_DOC_001 + def test_documentation_page_loads_with_table_of_contents(self, page, base_url): + test_id = "TC_DOC_001" + doc_url = build_url(base_url, url_patterns.documentation, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + toc_selectors = [ + page.locator('[class*="toc"], [id*="toc"]'), + page.locator('nav[aria-label*="table of contents" i]'), + page.locator(".sidebar, .navigation, [class*='sidebar']"), + page.locator("ul li a").first, + ] + toc_found = False + for selector in toc_selectors: + if selector.count() > 0: + try: + is_visible = selector.first.is_visible() + except Exception: + is_visible = False + if is_visible: + expect(selector.first).to_be_visible( + timeout=test_data.timeouts["medium"] + ) + _log(f"{test_id} Table of contents found") + toc_found = True + break + + _log( + f"{test_id} Documentation TOC verified" + if toc_found + else f"{test_id} No explicit TOC found" + ) + check_element_visibility( + page, + test_id, + selectors.content(page), + [], + "Documentation content", + test_id, + LOG, + ) + + # TC_DOC_002 + def test_library_documentation_links_are_accessible(self, page, base_url): + test_id = "TC_DOC_002" + libraries_url = build_url(base_url, url_patterns.libraries, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, libraries_url, test_id, LOG) + + popular_libraries = ["asio", "filesystem", "algorithm", "thread", "beast"] + found_libraries = 0 + + for lib_name in popular_libraries: + library_link = page.locator(f'a:has-text("{lib_name}")').first + if library_link.count() > 0: + try: + is_visible = library_link.is_visible() + except Exception: + is_visible = False + if is_visible: + href = library_link.get_attribute("href") + _log(f"{test_id} Found {lib_name} library: {href}") + assert href + found_libraries += 1 + + if found_libraries == 1: + library_link.click() + page.wait_for_load_state( + "networkidle", timeout=test_data.timeouts["medium"] + ) + new_url = page.url + _log(f"{test_id} Navigated to library doc: {new_url}") + has_doc_content = page.locator("h1, h2, main, article").count() + assert has_doc_content > 0 + page.goto(libraries_url, wait_until="networkidle") + + assert found_libraries > 0 + _log(f"{test_id} Found {found_libraries} library links") + + # TC_DOC_003 + def test_code_examples_are_properly_formatted(self, page, base_url): + test_id = "TC_DOC_003" + try: + doc_url = build_url( + base_url, url_patterns.doc_libs_version(), cachebust=True + ) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + code_selectors = [ + page.locator("pre code"), + page.locator(".code, .example-code"), + page.locator('[class*="code-"]'), + page.locator("pre"), + ] + code_block_found = False + for selector in code_selectors: + if selector.count() > 0: + first_code = selector.first + try: + is_visible = first_code.is_visible() + except Exception: + is_visible = False + if is_visible: + code_text = first_code.text_content() or "" + _log( + f"{test_id} Found code block with {len(code_text)} characters" + ) + if re.search(r"[{};()#include]", code_text): + _log(f"{test_id} Code block appears properly formatted") + code_block_found = True + break + + _log( + f"{test_id} Code examples verified" + if code_block_found + else f"{test_id} No code examples found on this page" + ) + except Exception as error: + _log(f"{test_id} Test failed: {error}") + if "closed" not in str(error): + raise + + # TC_DOC_004 + def test_documentation_breadcrumbs_navigation_works(self, page, base_url): + test_id = "TC_DOC_004" + doc_url = build_url(base_url, url_patterns.doc_libs_version(), cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + breadcrumb_selectors = [ + page.locator('[aria-label*="breadcrumb" i]'), + page.locator(".breadcrumb, .breadcrumbs"), + page.locator('[class*="breadcrumb"]'), + page.locator("nav ol, nav ul").first, + ] + breadcrumbs_found = False + for selector in breadcrumb_selectors: + if selector.count() > 0: + try: + is_visible = selector.first.is_visible() + except Exception: + is_visible = False + if is_visible: + _log(f"{test_id} Breadcrumbs found") + breadcrumbs_found = True + breadcrumb_links = selector.locator("a").all() + if breadcrumb_links: + first_link = breadcrumb_links[0] + href = first_link.get_attribute("href") + if href and href != "#": + url_before = page.url + first_link.click() + page.wait_for_load_state( + "networkidle", timeout=test_data.timeouts["medium"] + ) + url_after = page.url + _log( + f"{test_id} Breadcrumb navigation: {url_before} -> {url_after}" + ) + assert url_after != url_before + break + + _log( + f"{test_id} Breadcrumbs navigation verified" + if breadcrumbs_found + else f"{test_id} No breadcrumbs found" + ) + + # TC_DOC_005 + def test_documentation_version_switcher_works(self, page, base_url): + test_id = "TC_DOC_005" + doc_url = build_url(base_url, url_patterns.doc_libs_version(), cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + version_selectors = [ + page.locator('select[name*="version"], select[id*="version"]'), + page.locator('[data-testid*="version"]'), + page.locator(".version-switcher, .version-selector"), + ] + version_switcher = None + for selector in version_selectors: + if selector.count() > 0: + try: + is_visible = selector.first.is_visible() + except Exception: + is_visible = False + if is_visible: + version_switcher = selector.first + break + + if version_switcher: + expect(version_switcher).to_be_visible(timeout=test_data.timeouts["medium"]) + tag_name = version_switcher.evaluate("el => el.tagName.toLowerCase()") + if tag_name == "select": + options = version_switcher.locator("option").all() + option_count = len(options) + _log(f"{test_id} Found {option_count} version options") + assert option_count > 0 + if option_count > 1: + url_before = page.url + version_switcher.select_option(index=1) + page.wait_for_timeout(2000) + url_after = page.url + _log(f"{test_id} Version switch: {url_before} -> {url_after}") + else: + _log(f"{test_id} No version switcher found") + + # TC_DOC_006 + def test_documentation_search_within_docs_works(self, page, base_url): + test_id = "TC_DOC_006" + doc_url = build_url(base_url, url_patterns.documentation, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + doc_search_selectors = [ + page.locator('input[placeholder*="search doc" i]'), + page.locator('input[placeholder*="search librar" i]'), + page.locator(".doc-search input, .library-search input"), + page.locator('input[type="search"]'), + ] + search_input = None + for selector in doc_search_selectors: + if selector.count() > 0: + search_input = find_visible_element( + selector, "Doc search input", test_id, LOG + ) + if search_input: + break + + if search_input: + search_input.fill("boost") + search_input.press("Enter") + page.wait_for_timeout(2000) + results_found = page.locator("text=/result|found|match/i").count() > 0 + _log( + f"{test_id} Documentation search {'returned results' if results_found else 'executed but results format unclear'}" + ) + else: + _log(f"{test_id} No doc-specific search found - uses global search") + + # TC_DOC_007 + def test_documentation_anchor_links_work_correctly(self, page, base_url): + test_id = "TC_DOC_007" + try: + doc_url = build_url( + base_url, url_patterns.doc_libs_version(), cachebust=True + ) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + if page.is_closed(): + _log(f"{test_id} Page closed, skipping test") + return + + anchor_links = page.locator('a[href^="#"]').all() + _log(f"{test_id} Found {len(anchor_links)} anchor links") + + if anchor_links: + first_anchor = anchor_links[0] + href = first_anchor.get_attribute("href") + try: + is_visible = first_anchor.is_visible() + except Exception: + is_visible = False + + if is_visible and href and href != "#": + y_before = page.evaluate("window.scrollY") or 0 + try: + first_anchor.click() + except Exception: + _log(f"{test_id} Could not click anchor link") + page.wait_for_timeout(500) + y_after = page.evaluate("window.scrollY") or 0 + _log(f"{test_id} Anchor click: scroll from {y_before} to {y_after}") + except Exception as error: + _log(f"{test_id} Test error: {error}") + if "closed" not in str(error): + raise + + # TC_DOC_008 + def test_documentation_external_links_open_correctly(self, page, base_url): + test_id = "TC_DOC_008" + try: + doc_url = build_url(base_url, url_patterns.documentation, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + if page.is_closed(): + _log(f"{test_id} Page closed, skipping test") + return + + external_links = page.locator('a[href^="http"]').all() + _log(f"{test_id} Found {len(external_links)} external links") + + checked_links = 0 + for i, link in enumerate(external_links[:3]): + href = link.get_attribute("href") + try: + is_visible = link.is_visible() + except Exception: + is_visible = False + target = link.get_attribute("target") + if href and is_visible: + _log(f"{test_id} External link {i}: {href}, target={target}") + checked_links += 1 + + _log( + f"{test_id} Checked {checked_links} external links" + if checked_links + else f"{test_id} No external links found" + ) + except Exception as error: + _log(f"{test_id} Test error: {error}") + if "closed" not in str(error): + raise + + # TC_DOC_009 + def test_documentation_page_titles_are_descriptive(self, page, base_url): + test_id = "TC_DOC_009" + try: + doc_url = build_url(base_url, url_patterns.documentation, cachebust=True) + response = page.goto(doc_url, wait_until="domcontentloaded", timeout=20000) + if not response: + _log(f"{test_id} Could not load page, skipping test") + return + if page.is_closed(): + _log(f"{test_id} Page closed, skipping test") + return + + page_title = page.title() or "" + _log(f'{test_id} Page title: "{page_title}"') + + h1 = page.locator("h1").first + if h1.count() > 0: + _log(f'{test_id} H1 text: "{h1.text_content()}"') + else: + _log(f"{test_id} No H1 found on page") + + if len(page_title) > 10: + is_descriptive = any( + kw in page_title + for kw in ["Boost", "Documentation", "Library", "C++"] + ) + _log( + f"{test_id} Title {'is' if is_descriptive else 'may not be'} descriptive" + ) + else: + _log(f'{test_id} Title is too short: "{page_title}"') + except Exception as error: + _log(f"{test_id} Test error: {error}") + + # TC_DOC_010 + def test_documentation_pdf_print_versions_are_accessible(self, page, base_url): + test_id = "TC_DOC_010" + doc_url = build_url(base_url, url_patterns.documentation, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, doc_url, test_id, LOG) + + pdf_print_selectors = [ + page.locator('a[href*=".pdf"]'), + page.locator('a:has-text("PDF"), button:has-text("PDF")'), + page.locator('a:has-text("Print"), button:has-text("Print")'), + page.locator('[class*="print"], [class*="pdf"]'), + ] + pdf_print_found = False + for selector in pdf_print_selectors: + if selector.count() > 0: + try: + is_visible = selector.first.is_visible() + except Exception: + is_visible = False + if is_visible: + text = selector.first.text_content() + _log(f"{test_id} Found PDF/Print option: {text}") + pdf_print_found = True + break + + _log( + f"{test_id} PDF/Print version {'available' if pdf_print_found else 'not found - may not be offered'}" + ) diff --git a/qa/tests/test_download_search.py b/qa/tests/test_download_search.py new file mode 100644 index 000000000..e11733150 --- /dev/null +++ b/qa/tests/test_download_search.py @@ -0,0 +1,328 @@ +import re +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from playwright.sync_api import expect +from page_selectors import selectors +from config_helper import build_url, test_data, url_patterns +from helpers import ( + find_visible_element, + perform_search, + find_search_results, + test_patterns, +) + +LOG = "test-logs.txt" + + +def _log(message): + with open(LOG, "a") as f: + f.write(message + "\n") + + +class TestBoostDownload: + + # TC_DOWNLOAD_001 + def test_download_links_return_valid_http_status_codes(self, page, base_url): + test_id = "TC_DOWNLOAD_001" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, releases_url, test_id, LOG) + + download_selectors = [ + 'a[href*=".tar.gz"]', + 'a[href*=".zip"]', + 'a[href*="boost_1_"]', + 'a[href*="archives.boost.io"]', + ] + download_links = [] + for selector in download_selectors: + for link in page.locator(selector).all(): + href = link.get_attribute("href") + try: + is_visible = link.is_visible() + except Exception: + is_visible = False + if href and is_visible: + download_links.append({"element": link, "href": href}) + + _log(f"{test_id} Found {len(download_links)} download links") + assert len(download_links) > 0 + + for item in download_links[:5]: + href = item["href"] + try: + response = page.request.head(href, timeout=15000) + status = response.status + _log(f"{test_id} Download link: {href} - Status: {status}") + assert 200 <= status < 400 + except Exception as error: + _log(f"{test_id} Failed to check {href}: {error}") + + # TC_DOWNLOAD_002 + def test_download_file_names_are_correct_format(self, page, base_url): + test_id = "TC_DOWNLOAD_002" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, releases_url, test_id, LOG) + + download_links = page.locator( + 'a[href*="boost_1_"], a[href*=".tar.gz"], a[href*=".zip"]' + ).all() + _log(f"{test_id} Found {len(download_links)} download links") + + for link in download_links[:5]: + href = link.get_attribute("href") or "" + text = link.text_content() or "" + has_valid_format = bool( + re.search(r"boost_\d+_\d+_\d+\.(tar\.gz|zip|7z)", href) + or re.search(r"\d+\.\d+\.\d+", href) + ) + _log( + f"{test_id} Link: {text.strip()} -> {href}, Valid format: {has_valid_format}" + ) + if href and "boost" in href: + assert has_valid_format + + # TC_DOWNLOAD_003 + def test_version_selector_displays_available_versions(self, page, base_url): + test_id = "TC_DOWNLOAD_003" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, releases_url, test_id, LOG) + + version_selectors = [ + page.locator('select[name*="version"], select[id*="version"]'), + page.locator('select option[value*="1.8"]').locator(".."), + page.locator('[data-testid*="version"]'), + page.locator(".version-selector, #version-selector"), + ] + version_dropdown = None + for selector in version_selectors: + if selector.count() > 0: + version_dropdown = selector.first + try: + if version_dropdown.is_visible(): + break + except Exception: + version_dropdown = None + + if version_dropdown: + expect(version_dropdown).to_be_visible(timeout=test_data.timeouts["medium"]) + options = version_dropdown.locator("option").all() + option_texts = [opt.text_content() for opt in options] + _log( + f"{test_id} Available versions: {', '.join(t or '' for t in option_texts)}" + ) + assert len(options) > 0 + else: + _log( + f"{test_id} No version selector found - versions may be displayed differently" + ) + version_text_count = page.locator("text=/1\\.8\\d+|version/i").count() + assert version_text_count > 0 + + # TC_DOWNLOAD_004 + def test_download_page_displays_file_sizes(self, page, base_url): + test_id = "TC_DOWNLOAD_004" + releases_url = build_url(base_url, url_patterns.releases, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, releases_url, test_id, LOG) + + file_size_patterns = [ + page.locator("text=/\\d+\\s*(MB|GB|KB)/i"), + page.locator("text=/\\d+\\.\\d+\\s*(MB|GB)/i"), + page.locator('[class*="size"], [class*="file-size"]'), + ] + file_size_found = False + for pattern in file_size_patterns: + if pattern.count() > 0: + try: + is_visible = pattern.first.is_visible() + except Exception: + is_visible = False + if is_visible: + text = pattern.first.text_content() + _log(f"{test_id} Found file size: {text}") + file_size_found = True + break + + _log( + f"{test_id} File sizes {'displayed on download page' if file_size_found else 'not found - may not be displayed'}" + ) + + +class TestBoostSearch: + + # TC_SEARCH_001 + def test_search_returns_relevant_results_for_common_queries(self, page, base_url): + test_id = "TC_SEARCH_001" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + common_queries = ["asio", "filesystem", "algorithm"] + for query in common_queries: + perform_search(page, test_id, selectors, query, test_id, LOG) + result = find_search_results(page, query, test_id, LOG) + if result["element"] and result["count"] > 0: + expect(result["element"]).to_be_visible( + timeout=test_data.timeouts["medium"] + ) + result_text = (result["element"].text_content() or "").lower() + assert query.lower() in result_text + _log( + f'{test_id} Search for "{query}" returned {result["count"]} results' + ) + else: + _log(f'{test_id} No results found for "{query}"') + + page.goto(homepage_url, wait_until="networkidle") + page.wait_for_timeout(1000) + + # TC_SEARCH_002 + def test_search_with_special_characters_handles_gracefully(self, page, base_url): + test_id = "TC_SEARCH_002" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + special_queries = ["C++", "boost::asio"] + for query in special_queries: + try: + perform_search(page, test_id, selectors, query, test_id, LOG) + page.wait_for_timeout(3000) + has_error = page.locator("text=/error|500|crash/i").count() + assert has_error == 0 + _log(f'{test_id} Search with "{query}" handled gracefully') + page.goto(homepage_url, wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(1000) + except Exception as error: + _log(f'{test_id} Search with "{query}" noted: {error}') + try: + page.goto( + homepage_url, wait_until="domcontentloaded", timeout=30000 + ) + except Exception: + _log(f"{test_id} Could not recover, skipping remaining searches") + break + + # TC_SEARCH_003 + def test_empty_search_shows_appropriate_message(self, page, base_url): + test_id = "TC_SEARCH_003" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + try: + search_input = selectors.search(page) + if search_input.count() == 0: + _log(f"{test_id} No search input found, skipping test") + return + visible_search = find_visible_element( + search_input, "Search input", test_id, LOG + ) + if not visible_search: + _log(f"{test_id} Search input not visible, skipping test") + return + + visible_search.click() + visible_search.press("Enter") + page.wait_for_timeout(2000) + + messages = [ + page.locator("text=/please enter|required|empty/i"), + page.locator("text=/no results/i"), + page.locator('[role="alert"]'), + ] + message_found = any( + m.is_visible() + for m in messages + if m.count() > 0 and (lambda m=m: m.is_visible())() + ) + _log( + f"{test_id} Empty search message {'displayed' if message_found else 'prevented or handled silently'}" + ) + except Exception as error: + _log(f"{test_id} Test skipped: {error}") + + # TC_SEARCH_004 + def test_search_result_pagination_works_correctly(self, page, base_url): + test_id = "TC_SEARCH_004" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + perform_search(page, test_id, selectors, "boost", test_id, LOG) + page.wait_for_timeout(2000) + + pagination_selectors = [ + page.locator( + '[aria-label*="pagination"], [role="navigation"][aria-label*="page"]' + ), + page.locator(".pagination, .paging, .page-navigation"), + page.locator('a:has-text("Next"), button:has-text("Next")'), + page.locator('a:has-text("2"), button:has-text("2")'), + ] + pagination_found = False + for selector in pagination_selectors: + if selector.count() > 0: + try: + is_visible = selector.first.is_visible() + except Exception: + is_visible = False + if is_visible: + _log(f"{test_id} Pagination controls found") + pagination_found = True + next_button = page.locator( + 'a:has-text("Next"), button:has-text("Next")' + ).first + if next_button.count() > 0: + try: + if next_button.is_visible(): + url_before = page.url + next_button.click() + page.wait_for_timeout(2000) + _log( + f"{test_id} Pagination click: URL changed = {page.url != url_before}" + ) + except Exception: + pass + break + + if not pagination_found: + _log(f"{test_id} No pagination found - results may fit on one page") + + # TC_SEARCH_005 + def test_search_autocomplete_suggestions_appear(self, page, base_url): + test_id = "TC_SEARCH_005" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + try: + search_input = selectors.search(page) + visible_search = find_visible_element( + search_input, "Search input", test_id, LOG + ) + if not visible_search: + _log(f"{test_id} No search input found, skipping test") + return + + visible_search.fill("asi") + page.wait_for_timeout(1500) + + suggestion_selectors = [ + page.locator('[role="listbox"], [role="menu"]'), + page.locator(".autocomplete, .suggestions, .search-suggestions"), + page.locator('[class*="dropdown"][class*="search"]'), + page.locator('ul[class*="search"] li, div[class*="suggest"]'), + ] + suggestions_found = False + for selector in suggestion_selectors: + if selector.count() > 0: + try: + if selector.first.is_visible(): + suggestions_found = True + break + except Exception: + pass + + _log( + f"{test_id} Search {'autocomplete working' if suggestions_found else 'no autocomplete found - may not be implemented'}" + ) + except Exception as error: + _log(f"{test_id} Test skipped: {error}") diff --git a/qa/tests/test_error_handling.py b/qa/tests/test_error_handling.py new file mode 100644 index 000000000..8eadb7398 --- /dev/null +++ b/qa/tests/test_error_handling.py @@ -0,0 +1,244 @@ +import re +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from playwright.sync_api import expect +from page_selectors import selectors +from utils import log_and_screenshot, safe_goto +from config_helper import build_url, test_data, url_patterns +from helpers import find_visible_element, test_patterns + +LOG = "test-logs.txt" + + +def _log(message): + with open(LOG, "a") as f: + f.write(message + "\n") + + +class TestBoostErrorHandling: + + # TC_ERROR_001 + def test_404_page_displays_appropriate_error_message(self, page, base_url): + test_id = "TC_ERROR_001" + invalid_url = build_url( + base_url, "/this-page-does-not-exist-12345", cachebust=True + ) + + try: + safe_goto( + page, test_id, invalid_url, wait_until="networkidle", log_file=LOG + ) + except Exception: + _log(f"{test_id} Expected error when navigating to 404 page") + + error_indicators = [ + page.locator("text=/404|not found|page not found|doesn't exist/i").first, + page.locator("h1, h2, h3") + .filter(has_text=re.compile(r"404|not found", re.I)) + .first, + page.locator('[class*="error"]').first, + page.locator('[class*="404"]').first, + ] + error_found = False + for indicator in error_indicators: + if indicator.count() > 0: + try: + is_visible = indicator.is_visible() + except Exception: + is_visible = False + if is_visible: + expect(indicator).to_be_visible(timeout=test_data.timeouts["short"]) + error_found = True + _log(f"{test_id} 404 error message displayed correctly") + break + + if not error_found: + log_and_screenshot( + page, + test_id, + "404 error message not found", + "screenshots/tc_error_001_no_404.png", + LOG, + ) + title = page.title() + url = page.url + _log(f'{test_id} Page title: "{title}", URL: "{url}"') + if "404" in title.lower() or "not found" in title.lower(): + _log(f"{test_id} 404 indicated in page title") + error_found = True + + assert error_found + + # TC_ERROR_002 + def test_broken_documentation_link_returns_appropriate_error(self, page, base_url): + test_id = "TC_ERROR_002" + broken_doc_url = build_url( + base_url, "/doc/libs/nonexistent-library", cachebust=True + ) + + response = None + try: + response = page.goto( + broken_doc_url, + wait_until="networkidle", + timeout=test_data.timeouts["medium"], + ) + except Exception: + pass + + if response: + status = response.status + _log(f"{test_id} Response status: {status}") + assert 400 <= status < 500 + + error_message = page.locator( + "text=/error|not found|invalid|doesn't exist/i" + ).first + expect(error_message).to_be_visible(timeout=test_data.timeouts["medium"]) + _log(f"{test_id} Error message displayed for broken doc link") + + # TC_ERROR_003 + def test_invalid_search_query_handles_gracefully(self, page, base_url): + test_id = "TC_ERROR_003" + homepage_url = build_url(base_url, url_patterns.homepage, cachebust=True) + test_patterns.load_and_validate_page(page, test_id, homepage_url, test_id, LOG) + + try: + search_input = selectors.search(page) + visible_search = find_visible_element( + search_input, "Search input", test_id, LOG + ) + if not visible_search: + _log(f"{test_id} No search input found, skipping test") + return + + invalid_terms = ["