import json import uuid from dataclasses import dataclass from bs4 import BeautifulSoup, Tag from fastcore.xml import FT, to_xml from fasthtml.common import FastHTML from starlette.responses import Response from starlette.testclient import TestClient from myfasthtml.core.commands import mount_commands @dataclass class MyFT: tag: str attrs: dict class TestableElement: """ Represents an HTML element that can be interacted with in tests. This class will be used for future interactions like clicking elements or verifying element properties. """ def __init__(self, client, source): """ Initialize a testable element. Args: client: The MyTestClient instance. ft: The FastHTML element representation. """ self.client = client if isinstance(source, str): self.html_fragment = source tag = BeautifulSoup(source, 'html.parser').find() self.ft = MyFT(tag.name, tag.attrs) elif isinstance(source, Tag): self.html_fragment = str(source) self.ft = MyFT(source.name, source.attrs) elif isinstance(source, FT): self.ft = source self.html_fragment = to_xml(source).strip() else: raise ValueError(f"Invalid source '{source}' for TestableElement.") def click(self): """Click the element (to be implemented).""" return self._send_htmx_request() def matches(self, ft): """Check if element matches given FastHTML element (to be implemented).""" pass def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response: """ Simulates an HTMX request in Python for unit testing. This function reads the 'hx-*' attributes from the FastHTML object to determine the HTTP method, URL, headers, and body of the request, then executes it via the TestClient. Args: data: (Optional) A dict for form data (sends as 'application/x-www-form-urlencoded'). json_data: (Optional) A dict for JSON data (sends as 'application/json'). Takes precedence over 'hx_vals'. Returns: The Response object from the simulated request. """ # The essential header for FastHTML (and HTMX) to identify the request headers = {"HX-Request": "true"} method = "GET" # HTMX defaults to GET if not specified url = None verbs = { 'hx_get': 'GET', 'hx_post': 'POST', 'hx_put': 'PUT', 'hx_delete': 'DELETE', 'hx_patch': 'PATCH', } # .props contains the kwargs passed to the object (e.g., hx_post="/url") element_attrs = self.ft.attrs or {} # Build the attributes for key, value in element_attrs.items(): # sanitize the key key = key.lower().strip() if key.startswith('hx-'): key = 'hx_' + key[3:] if key in verbs: # Verb attribute: defines the method and URL method = verbs[key] url = str(value) elif key == 'hx_vals': # hx_vals defines the JSON body, if not already provided by the test if json_data is None: if isinstance(value, str): json_data = json.loads(value) elif isinstance(value, dict): json_data = value elif key.startswith('hx_'): # Any other hx_* attribute is converted to an HTTP header # e.g.: 'hx_target' -> 'HX-Target' header_name = '-'.join(p.capitalize() for p in key.split('_')) headers[header_name] = str(value) # Sanity check if url is None: raise ValueError( f"The <{self.ft.tag}> element has no HTMX verb attribute " "(e.g., hx_get, hx_post) to define a URL." ) # Send the request return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data) class MyTestClient: """ A test client helper for FastHTML applications that provides a more user-friendly API for testing HTML responses. This class wraps Starlette's TestClient and provides methods to verify page content in a way similar to NiceGui's test fixtures. """ def __init__(self, app: FastHTML, parent_levels: int = 1): """ Initialize the test client. Args: app: The FastHTML application to test. parent_levels: Number of parent levels to show in error messages (default: 1). """ self.app = app self.client = TestClient(app) self._content = None self._soup = None self._session = str(uuid.uuid4()) self.parent_levels = parent_levels # make sure that the commands are mounted mount_commands(self.app) def open(self, path: str): """ Open a page and store its content for subsequent assertions. Args: path: The URL path to request (e.g., '/home', '/api/users'). Returns: self: Returns the client instance for method chaining. Raises: AssertionError: If the response status code is not 200. """ res = self.client.get(path) assert res.status_code == 200, ( f"Failed to open '{path}'. " f"status code={res.status_code} : reason='{res.text}'" ) self.set_content(res.text) return self def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None): json_data['session'] = self._session res = self.client.request( method, url, headers=headers, data=data, # For form data json=json_data # For JSON bodies (e.g., from hx_vals) ) assert res.status_code == 200, ( f"Failed to send request '{method=}', {url=}. " f"status code={res.status_code} : reason='{res.text}'" ) self.set_content(res.text) return self def should_see(self, text: str): """ Assert that the given text is present in the visible page content. This method parses the HTML and searches only in the visible text, ignoring HTML tags and attributes. Args: text: The text string to search for (case-sensitive). Returns: self: Returns the client instance for method chaining. Raises: AssertionError: If the text is not found in the page content. ValueError: If no page has been opened yet. """ if self._content is None: raise ValueError( "No page content available. Call open() before should_see()." ) visible_text = self._soup.get_text() if text not in visible_text: # Provide a snippet of the actual content for debugging snippet_length = 200 content_snippet = ( visible_text[:snippet_length] + "..." if len(visible_text) > snippet_length else visible_text ) raise AssertionError( f"Expected to see '{text}' in page content but it was not found.\n" f"Visible content (first {snippet_length} chars): {content_snippet}" ) return self def should_not_see(self, text: str): """ Assert that the given text is NOT present in the visible page content. This method parses the HTML and searches only in the visible text, ignoring HTML tags and attributes. Args: text: The text string that should not be present (case-sensitive). Returns: self: Returns the client instance for method chaining. Raises: AssertionError: If the text is found in the page content. ValueError: If no page has been opened yet. """ if self._content is None: raise ValueError( "No page content available. Call open() before should_not_see()." ) visible_text = self._soup.get_text() if text in visible_text: element = self._find_visible_text_element(self._soup, text) if element: context = self._format_element_with_context(element, self.parent_levels) error_msg = ( f"Expected NOT to see '{text}' in page content but it was found.\n" f"Found in:\n{context}" ) else: error_msg = ( f"Expected NOT to see '{text}' in page content but it was found.\n" f"Unable to locate the element containing this text." ) raise AssertionError(error_msg) return self def find_element(self, selector: str): """ Find a single HTML element using a CSS selector. This method searches for elements matching the given CSS selector. It expects to find exactly one matching element. Args: selector: A CSS selector string (e.g., '#my-id', '.my-class', 'button.primary'). Returns: TestableElement: A testable element wrapping the HTML fragment. Raises: ValueError: If no page has been opened yet. AssertionError: If no element or multiple elements match the selector. Examples: element = client.open('/').find_element('#login-button') element = client.find_element('button.primary') """ if self._content is None: raise ValueError( "No page content available. Call open() before find_element()." ) results = self._soup.select(selector) if len(results) == 0: raise AssertionError( f"No element found matching selector '{selector}'." ) elif len(results) == 1: return TestableElement(self, results[0]) else: raise AssertionError( f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." ) def get_content(self) -> str: """ Get the raw HTML content of the last opened page. Returns: The HTML content as a string, or None if no page has been opened. """ return self._content def set_content(self, content: str): """ Set the HTML content and parse it with BeautifulSoup. Args: content: The HTML content string to set. """ self._content = content self._soup = BeautifulSoup(content, 'html.parser') @staticmethod def _find_visible_text_element(soup, text: str): """ Find the first element containing the visible text. This method traverses the BeautifulSoup tree to find the first element whose visible text content (including descendants) contains the search text. Args: soup: BeautifulSoup object representing the parsed HTML. text: The text to search for. Returns: BeautifulSoup element containing the text, or None if not found. """ # Traverse all elements in the document for element in soup.descendants: # Skip NavigableString nodes, we want Tag elements if not isinstance(element, Tag): continue # Get visible text of this element and its descendants element_text = element.get_text() # Check if our search text is in this element's visible text if text in element_text: # Found it! But we want the smallest element containing the text # So let's check if any of its children also contain the text found_in_child = False for child in element.children: if isinstance(child, Tag) and text in child.get_text(): found_in_child = True break # If no child contains the text, this is our target element if not found_in_child: return element return None @staticmethod def _indent_html(html_str: str, indent: int = 2): """ Add indentation to HTML string. Args: html_str: HTML string to indent. indent: Number of spaces for indentation. Returns: str: Indented HTML string. """ lines = html_str.split('\n') indented_lines = [' ' * indent + line for line in lines if line.strip()] return '\n'.join(indented_lines) def _format_element_with_context(self, element, parent_levels: int): """ Format an element with its parent context for display. Args: element: BeautifulSoup element to format. parent_levels: Number of parent levels to include. Returns: str: Formatted HTML string with indentation. """ # Collect the element and its parents elements_to_show = [element] current = element for _ in range(parent_levels): if current.parent and current.parent.name: # Skip NavigableString parents elements_to_show.insert(0, current.parent) current = current.parent else: break # Format the top-level element with proper indentation if len(elements_to_show) == 1: return self._indent_html(str(element), indent=2) # Build the nested structure result = self._build_nested_context(elements_to_show, element) return self._indent_html(result, indent=2) def _build_nested_context(self, elements_chain, target_element): """ Build nested HTML context showing parents and target element. Args: elements_chain: List of elements from outermost parent to target. target_element: The element that contains the searched text. Returns: str: Nested HTML structure. """ if len(elements_chain) == 1: return str(target_element) # Get the outermost element outer = elements_chain[0] # Start with opening tag result = f"<{outer.name}" if outer.attrs: attrs = ' '.join(f'{k}="{v}"' if not isinstance(v, list) else f'{k}="{" ".join(v)}"' for k, v in outer.attrs.items()) result += f" {attrs}" result += ">\n" # Add nested content if len(elements_chain) == 2: # This is the target element result += self._indent_html(str(target_element), indent=2) + "\n" else: # Recursive call for deeper nesting nested = self._build_nested_context(elements_chain[1:], target_element) result += self._indent_html(nested, indent=2) + "\n" # Closing tag result += f"" return result