4 Commits

28 changed files with 89131 additions and 2 deletions

180
README.md
View File

@@ -1,3 +1,179 @@
# MyFastHtml Module
# MyFastHtml
Set of tools to quickly create HTML pages using FastHTML.
A utility library designed to simplify the development of FastHtml applications by providing:
- Predefined pages for common functionalities (e.g., authentication, user management).
- A command management system to facilitate client-server interactions.
- Helpers to create interactive controls more easily.
- A system for control state persistence.
---
## Features
- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like `/commands`.
- **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX.
- **Control helpers**: Easily create reusable components like buttons.
- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards.
> _**Note:** Support for state persistence is currently under construction._
---
## Installation
Ensure you have Python >= 3.12 installed, then install the library with `pip`:
```bash
pip install myfasthtml
```
---
## Quick Start
Heres a simple example of creating an **interactive button** linked to a command:
### Example: Button with a Command
```python
from fasthtml.fastapp import fast_app
from myfasthtml.core.commands import Command
from myfasthtml.controls.button import mk_button
# Define a simple command action
def say_hello():
return "Hello, FastHtml!"
# Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
# Create the app and define a route with a button
app, rt = fast_app(default_hdrs=False)
@rt("/")
def get_homepage():
return mk_button("Click Me!", command=hello_command)
```
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
- HTMX automatically handles the client-server interaction behind the scenes.
---
## Planned Features (Roadmap)
### Predefined Pages
The library will include predefined pages for:
- **Authentication**: Login, signup, password reset.
- **User Management**: User profile and administration pages.
- **Dashboard Templates**: Fully customizable dashboard components.
- **Error Pages**: Detailed and styled error messages (e.g., 404, 500).
### State Persistence
Controls will have their state automatically synchronized between the client and the server. This feature is currently under construction.
---
## Advanced Features
### Command Management System
Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define Python methods and handle them as commands.
#### Example
Heres how `Command` simplifies dynamic interaction:
```python
from myfasthtml.core.commands import Command
# Define a command
def custom_action(data):
return f"Received: {data}"
my_command = Command("custom", "Handles custom logic", custom_action)
# Get the HTMX parameters automatically
htmx_attrs = my_command.get_htmx_params()
print(htmx_attrs)
# Output:
# {
# "hx-post": "/commands",
# "hx-vals": '{"c_id": "unique-command-id"}'
# }
```
Use the `get_htmx_params()` method to directly integrate commands into HTML components.
---
## Contributing
We welcome contributions! To get started:
1. Fork the repository.
2. Create a feature branch.
3. Submit a pull request with clear descriptions of your changes.
For detailed guidelines, see the [Contributing Section](./CONTRIBUTING.md) (coming soon).
---
## License
This project is licensed under the terms of the MIT License. See the `LICENSE` file for details.
---
## Technical Overview
### Project Structure
```
MyFastHtml
├── src
│ ├── myfasthtml/ # Main library code
│ │ ├── core/commands.py # Command definitions
│ │ ├── controls/button.py # Control helpers
│ │ └── pages/LoginPage.py # Predefined Login page
│ └── ...
├── tests # Unit and integration tests
├── LICENSE # License file (MIT)
├── README.md # Project documentation
└── pyproject.toml # Build configuration
```
### Notable Classes and Methods
#### 1. `Command`
Represents a backend action with server communication.
- **Attributes:**
- `id`: Unique identifier for the command.
- `name`: Command name (e.g., `say_hello`).
- `description`: Description of the command.
- **Method:** `get_htmx_params()` generates HTMX attributes.
#### 2. `mk_button`
Simplifies the creation of interactive buttons linked to commands.
- **Arguments:**
- `element` (str): The label for the button.
- `command` (Command): Command associated with the button.
- `kwargs`: Additional button attributes.
#### 3. `LoginPage`
Predefined login page that provides a UI template ready for integration.
- **Constructor Parameters:**
- `settings_manager`: Configuration/settings object.
- `error_message`: Optional error message to display.
- `success_message`: Optional success message to display.
---
## Entry Points
- `/commands`: Handles HTMX requests from the command attributes.
---
## Exceptions
No custom exceptions defined yet. (Placeholder for future use.)
```

34
requirements.txt Normal file
View File

@@ -0,0 +1,34 @@
anyio==4.11.0
apsw==3.50.4.0
apswutils==0.1.0
beautifulsoup4==4.14.2
certifi==2025.10.5
click==8.3.0
fastcore==1.8.13
fastlite==0.2.1
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.3.0
itsdangerous==2.2.0
oauthlib==3.3.1
packaging==25.0
pluggy==1.6.0
Pygments==2.19.2
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-fasthtml==0.12.30
python-multipart==0.0.20
PyYAML==6.0.3
six==1.17.0
sniffio==1.3.1
soupsieve==2.8
starlette==0.48.0
typing_extensions==4.15.0
uvicorn==0.38.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1

View File

View File

@@ -0,0 +1,11 @@
from fasthtml.components import *
from myfasthtml.core.commands import Command
def mk_button(element, command: Command = None, **kwargs):
if command is None:
return Button(element, **kwargs)
htmx = command.get_htmx_params()
return Button(element, **htmx, **kwargs)

View File

View File

@@ -0,0 +1,122 @@
import logging
import uuid
from typing import Optional
from fasthtml.fastapp import fast_app
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import mount_if_not_exists
commands_app, commands_rt = fast_app()
logger = logging.getLogger("Commands")
class BaseCommand:
"""
Represents the base command class for defining executable actions.
This class serves as a foundation for commands that can be registered,
executed, and utilized within a system. Each command has a unique identifier,
a name, and a description. Commands should override the `execute` method
to provide specific functionality.
:ivar id: A unique identifier for the command.
:type id: uuid.UUID
:ivar name: The name of the command.
:type name: str
:ivar description: A brief description of the command's functionality.
:type description: str
"""
def __init__(self, name, description):
self.id = uuid.uuid4()
self.name = name
self.description = description
# register the command
CommandsManager.register(self)
def get_htmx_params(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}',
}
def execute(self):
raise NotImplementedError
class Command(BaseCommand):
"""
Represents a command that encapsulates a callable action with parameters.
This class is designed to hold a defined action (callback) alongside its arguments
and keyword arguments.
:ivar name: The name of the command.
:type name: str
:ivar description: A brief description of the command.
:type description: str
:ivar callback: The function or callable to be executed.
:type callback: Callable
:ivar args: Positional arguments to be passed to the callback.
:type args: tuple
:ivar kwargs: Keyword arguments to be passed to the callback.
:type kwargs: dict
"""
def __init__(self, name, description, callback, *args, **kwargs):
super().__init__(name, description)
self.callback = callback
self.args = args
self.kwargs = kwargs
def execute(self):
return self.callback(*self.args, **self.kwargs)
def __str__(self):
return f"Command({self.name})"
class CommandsManager:
commands = {}
@staticmethod
def register(command: BaseCommand):
CommandsManager.commands[str(command.id)] = command
@staticmethod
def get_command(command_id: str) -> Optional[BaseCommand]:
return CommandsManager.commands.get(command_id)
@staticmethod
def reset():
return CommandsManager.commands.clear()
@commands_rt(Routes.Commands)
def post(session: str, c_id: str):
"""
Default routes for all commands.
:param session:
:param c_id:
:return:
"""
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
command = CommandsManager.get_command(c_id)
if command:
return command.execute()
raise ValueError(f"Command with ID '{c_id}' not found.")
def mount_commands(app):
"""
Mounts the commands_app to the given application instance if the route does not already exist.
:param app: The application instance to which the commands_app will be mounted.
:type app: Any
:return: Returns the result of the mount operation performed by mount_if_not_exists.
:rtype: Any
"""
return mount_if_not_exists(app, ROUTE_ROOT, commands_app)

View File

@@ -0,0 +1,4 @@
ROUTE_ROOT = "/myfasthtml"
class Routes:
Commands = "/commands"

View File

@@ -0,0 +1,345 @@
import re
from dataclasses import dataclass
from fastcore.basics import NotStr
from myfasthtml.core.testclient import MyFT
class Predicate:
def __init__(self, value):
self.value = value
def validate(self, actual):
raise NotImplementedError
def __str__(self):
return f"{self.__class__.__name__}({self.value})"
def __eq__(self, other):
if not isinstance(other, Predicate):
return False
return self.value == other.value
def __hash__(self):
return hash(self.value)
class StartsWith(Predicate):
def __init__(self, value):
super().__init__(value)
def validate(self, actual):
return actual.startswith(self.value)
class Contains(Predicate):
def __init__(self, value):
super().__init__(value)
def validate(self, actual):
return self.value in actual
class DoesNotContain(Predicate):
def __init__(self, value):
super().__init__(value)
def validate(self, actual):
return self.value not in actual
@dataclass
class DoNotCheck:
desc: str = None
@dataclass
class Empty:
desc: str = None
class ErrorOutput:
def __init__(self, path, element, expected):
self.path = path
self.element = element
self.expected = expected
self.output = []
self.indent = ""
@staticmethod
def _unconstruct_path_item(item):
if "#" in item:
elt_name, elt_id = item.split("#")
return elt_name, "id", elt_id
elif match := re.match(r'(\w+)\[(class|name)=([^]]+)]', item):
return match.groups()
return item, None, None
def __str__(self):
self.compute()
def compute(self):
# first render the path hierarchy
for p in self.path.split(".")[:-1]:
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
self._add_to_output(f"{path_str}")
self.indent += " "
# then render the element
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
# display the tag and its attributes
tag_str = self._str_element(self.element, self.expected)
self._add_to_output(tag_str)
# Try to show where the differences are
error_str = self._detect_error(self.element, self.expected)
if error_str:
self._add_to_output(error_str)
# render the children
if len(self.expected.children) > 0:
self.indent += " "
element_index = 0
for expected_child in self.expected.children:
if hasattr(expected_child, "tag"):
if element_index < len(self.element.children):
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors in children
child_error_str = self._detect_error(element_child, expected_child)
if child_error_str:
self._add_to_output(child_error_str)
element_index += 1
else:
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
else:
self._add_to_output(expected_child)
self.indent = self.indent[:-2]
self._add_to_output(")")
else:
self._add_to_output(str(self.element))
# Try to show where the differences are
error_str = self._detect_error(self.element, self.expected)
if error_str:
self._add_to_output(error_str)
def _add_to_output(self, msg):
self.output.append(f"{self.indent}{msg}")
@staticmethod
def _str_element(element, expected=None, keep_open=None):
# compare to itself if no expected element is provided
if expected is None:
expected = element
# the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
#
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
if len(element.children) == 0: tag_str += ")"
return tag_str
def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"):
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
not self._matches(attr_value, expected.attrs[attr_name])]
if attrs_in_error:
elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in elt_attrs.items())
error_str = f" {tag_str} {elt_attrs_error}"
return error_str
else:
if not self._matches(element, expected):
return len(str(element)) * "^"
return None
@staticmethod
def _matches(element, expected):
if element == expected:
return True
elif isinstance(expected, Predicate):
return expected.validate(element)
else:
return element == expected
class ErrorComparisonOutput:
def __init__(self, actual_error_output, expected_error_output):
self.actual_error_output = actual_error_output
self.expected_error_output = expected_error_output
@staticmethod
def adjust(to_adjust, reference):
for index, ref_line in enumerate(reference):
if "^^" in ref_line:
# insert an empty line in to_adjust
to_adjust.insert(index, "")
return to_adjust
def render(self):
# init if needed
if not self.actual_error_output.output:
self.actual_error_output.compute()
if not self.expected_error_output.output:
self.expected_error_output.compute()
actual = self.actual_error_output.output
expected = self.expected_error_output.output
# actual = self.adjust(actual, expected) # does not seem to be needed
expected = self.adjust(expected, actual)
actual_max_length = len(max(actual, key=len))
# expected_max_length = len(max(expected, key=len))
output = []
for a, e in zip(actual, expected):
line = f"{a:<{actual_max_length}} | {e}".rstrip()
output.append(line)
return "\n".join(output)
def matches(actual, expected, path=""):
def print_path(p):
return f"Path : '{p}'\n" if p else ""
def _type(x):
return type(x)
def _debug(elt):
return str(elt) if elt else "None"
def _debug_compare(a, b):
actual_out = ErrorOutput(path, a, b)
expected_out = ErrorOutput(path, b, b)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
return comparison_out.render()
def _error_msg(msg, _actual=None, _expected=None):
if _actual is None and _expected is None:
debug_info = ""
elif _actual is None:
debug_info = _debug(_expected)
elif _expected is None:
debug_info = _debug(_actual)
else:
debug_info = _debug_compare(_actual, _expected)
return f"{print_path(path)}Error : {msg}\n{debug_info}"
def _assert_error(msg, _actual=None, _expected=None):
assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
def _get_current_path(elt):
if hasattr(elt, "tag"):
res = f"{elt.tag}"
if "id" in elt.attrs:
res += f"#{elt.attrs['id']}"
elif "name" in elt.attrs:
res += f"[name={elt.attrs['name']}]"
elif "class" in elt.attrs:
res += f"[class={elt.attrs['class']}]"
return res
else:
return elt.__class__.__name__
if actual is not None and expected is None:
_assert_error("Actual is not None while expected is None", _actual=actual)
if isinstance(expected, DoNotCheck):
return True
if actual is None and expected is not None:
_assert_error("Actual is None while expected is ", _expected=expected)
# set the path
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
_error_msg("The types are different: ", _actual=actual, _expected=expected)
if isinstance(expected, (list, tuple)):
if len(actual) < len(expected):
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual, expected):
assert matches(actual_child, expected_child, path=path)
elif isinstance(expected, NotStr):
to_compare = actual.s.lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
_actual=to_compare,
_expected=expected.s)
elif hasattr(expected, "tag"):
# validate the tags names
assert actual.tag == expected.tag, _error_msg("The elements are different.",
_actual=actual.tag,
_expected=expected.tag)
# special case when the expected element is empty
if len(expected.children) > 0 and isinstance(expected.children[0], Empty):
assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual)
assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual)
return True
# compare the attributes
for expected_attr, expected_value in expected.attrs.items():
assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.",
_actual=actual.attrs)
if isinstance(expected_value, Predicate):
assert expected_value.validate(actual.attrs[expected_attr]), \
_error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual,
_expected=expected)
else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
_error_msg(f"The values are different for '{expected_attr}': ",
_actual=actual.attrs[expected_attr],
_expected=expected.attrs[expected_attr])
# compare the children
if len(actual.children) < len(expected.children):
_assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual.children, expected.children):
assert matches(actual_child, expected_child, path=path)
else:
assert actual == expected, _error_msg("The values are different: ",
_actual=actual,
_expected=expected)
return True

View File

@@ -0,0 +1,466 @@
import dataclasses
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
children: list['MyFT'] = dataclasses.field(default_factory=list)
text: str | None = None
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"</{outer.name}>"
return result

View File

@@ -0,0 +1,19 @@
from starlette.routing import Mount
def mount_if_not_exists(app, path: str, sub_app):
"""
Mounts a sub-application only if no Mount object already exists
at the specified path in the main application's router.
"""
is_mounted = False
for route in app.router.routes:
if isinstance(route, Mount):
if route.path == path:
is_mounted = True
break
if not is_mounted:
app.mount(path, app=sub_app)

View File

@@ -0,0 +1,21 @@
# Generate Icons
Create the icons files for FastHtml applications.
## How to generate the svg files
```sh
npm i -D @sicons/fa
npm i -D @sicons/fluent
npm i -D @sicons/ionicons4
npm i -D @sicons/ionicons5
npm i -D @sicons/antd
npm i -D @sicons/material
npm i -D @sicons/tabler
npm i -D @sicons/carbon
```
Update the root folder in `update_icons.py` to point to the root folder of the icons.
##
```sh
python update_icons.py
```

View File

1679
src/myfasthtml/icons/antd.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1617
src/myfasthtml/icons/fa.py Normal file

File diff suppressed because one or more lines are too long

30900
src/myfasthtml/icons/fluent.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

10870
src/myfasthtml/icons/tabler.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import os
root_folder = "/home/kodjo/Dev/MyDocManager/src/frontend/node_modules/@sicons"
import re
def pascal_to_snake(name: str) -> str:
"""Convert a PascalCase or CamelCase string to snake_case."""
# Insert underscore before capital letters (except the first one)
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()
def create_icons(file, icon_folder):
for filename in os.listdir(f"{root_folder}/{icon_folder}"):
print("#", end='')
if not filename.endswith(".svg"):
continue
with open(f"{root_folder}/{icon_folder}/{filename}", "r") as f_read:
svg_content = f_read.read().strip()
icon_name = "icon_" + pascal_to_snake(filename.split('.')[0])
file.write(f"{icon_name} = NotStr('''{svg_content}''')\n")
print("")
if __name__ == "__main__":
for folder in ["antd", "material", "carbon", "fa", "fluent", "ionicons4", "ionicons5", "tabler"]:
# for folder in ["antd"]:
print(f"Processing icons for {folder}")
with open(f"{folder}.py", "w") as f_write:
# Add README.md content to the top of the file
if os.path.exists(f"{root_folder}/{folder}/README.md"):
with open(f"{root_folder}/{folder}/README.md", "r") as f_readme:
for line in f_readme:
if line.startswith("#"):
f_write.write(line)
else:
f_write.write(f"# {line}")
f_write.write("\n\n")
# Add imports
f_write.write("from fastcore.basics import NotStr\n\n")
# Add icons
create_icons(f_write, folder)

View File

@@ -0,0 +1,86 @@
from fasthtml.components import *
class LoginPage:
def __init__(self, settings_manager, error_message=None, success_message=None):
self.settings_manager = settings_manager
self.error_message = error_message
self.success_message = success_message
def render(self):
message_alert = None
if self.error_message:
message_alert = Div(
P(self.error_message, cls="text-sm"),
cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
)
elif self.success_message:
message_alert = Div(
P(self.success_message, cls="text-sm"),
cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
)
return Div(
# Page title
H1("Sign In", cls="text-3xl font-bold text-center mb-6"),
# Login Form
Div(
# Message alert
message_alert if message_alert else "",
# Email login form
Form(
# Email field
Div(
Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="email",
id="email",
name="email",
placeholder="you@example.com",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-4"
),
# Password field
Div(
Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="password",
id="password",
name="password",
placeholder="Your password",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-6"
),
# Submit button
Button(
"Sign In",
type="submit",
cls="btn w-full font-bold py-2 px-4 rounded"
),
action=ROUTE_ROOT + Routes.LoginByEmail,
method="post",
cls="mb-6"
),
# Registration link
Div(
P(
"Don't have an account? ",
A("Register here", href="/register", cls="text-blue-600 hover:underline"),
cls="text-sm text-gray-600 text-center"
)
),
cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
)
)

View File

25
tests/test_commands.py Normal file
View File

@@ -0,0 +1,25 @@
import pytest
from myfasthtml.core.commands import Command, CommandsManager
def callback():
return "Hello World"
@pytest.fixture(autouse=True)
def test_reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command

View File

@@ -0,0 +1,53 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.button import mk_button
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
def test_i_can_trigger_a_command(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk_button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk_button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk_button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")

338
tests/test_matches.py Normal file
View File

@@ -0,0 +1,338 @@
import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput
from myfasthtml.core.testclient import MyFT
@pytest.mark.parametrize('actual, expected', [
(None, None),
(123, 123),
(Div(), Div()),
([Div(), Span()], [Div(), Span()]),
(Div(attr1="value"), Div(attr1="value")),
(Div(attr1="value", attr2="value"), Div(attr1="value")),
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
])
def test_i_can_match(actual, expected):
assert matches(actual, expected)
@pytest.mark.parametrize('actual, expected, error_message', [
(None, Div(), "Actual is None"),
(Div(), None, "Actual is not None"),
(123, Div(), "The types are different"),
(123, 124, "The values are different"),
([Div(), Span()], [], "Actual is bigger than expected"),
([], [Div(), Span()], "Actual is smaller than expected"),
("not a list", [Div(), Span()], "The types are different"),
([Div(), Span()], [Div(), 123], "The types are different"),
(Div(), Span(), "The elements are different"),
([Div(), Span()], [Div(), Div()], "The elements are different"),
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "Actual is not empty"),
(Div(120), Div(Empty()), "Actual is not empty"),
(Div(Span()), Div(Empty()), "Actual is not empty"),
(Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"),
(Div(123), Div(Div()), "The types are different"),
(Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
])
def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
assert error_message in str(exc_info.value)
@pytest.mark.parametrize('element, expected_path', [
(Div(), "Path : 'div"),
(Div(Span()), "Path : 'div.span"),
(Div(Span(Div())), "Path : 'div.span.div"),
(Div(id="div_id"), "Path : 'div#div_id"),
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
(Div(name="div_class"), "Path : 'div[name=div_class]"),
(Div(attr="value"), "Path : 'div"),
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
])
def test_i_can_properly_show_path(element, expected_path):
def _construct_test_element(source, tail):
res = MyFT(source.tag, source.attrs)
if source.children:
res.children = [_construct_test_element(child, tail) for child in source.children]
else:
res.children = [tail]
return res
with pytest.raises(AssertionError) as exc_info:
actual = _construct_test_element(element, "Actual")
expected = _construct_test_element(element, "Expected")
matches(actual, expected)
assert expected_path in str(exc_info.value)
def test_i_can_output_error_path():
elt = Div()
expected = Div()
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "id"="div_id" ...',
' (div ...',
' (span "class"="span_class" ...',
' (p "name"="p_name" ...',
' (div )']
def test_i_can_output_error_attribute():
elt = Div(attr1="value1", attr2="value2")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
def test_i_can_output_error_attribute_missing_1():
elt = Div(attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_attribute_missing_2():
elt = Div(attr1="value1")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
' ^^^^^^^^^^^^^^^^^^^^^^^']
def test_i_can_output_error_attribute_wrong_value():
elt = Div(attr1="value3", attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_constant():
elt = 123
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123']
def test_i_can_output_error_constant_wrong_value():
elt = 123
expected = 456
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123',
'^^^']
def test_i_can_output_error_when_predicate():
elt = "before value after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before value after"]
def test_i_can_output_error_when_predicate_wrong_value():
elt = "before after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before after",
"^^^^^^^^^^^^"]
def test_i_can_output_error_child_element():
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1" ...)',
')',
]
def test_i_can_output_error_child_element_wrong_value():
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_2")',
' ^^^^^^^^^^^^^^',
')',
]
def test_i_can_output_error_fewer_elements():
elt = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' ! ** MISSING ** !',
')',
]
def test_i_can_output_comparison():
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_with_path():
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "id"="div_id" ... | (div "id"="div_id" ...
(span "class"="cls" ... | (span "class"="cls" ...
(div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_when_missing_attributes():
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), attr2="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr2"="** MISSING **" | (div "attr2"="value1"
^^^^^^^^^^^^^^^^^^^^^^^ |
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_when_wrong_attributes():
actual = Div(P(id="p_id"), attr1="value2")
expected = Div(P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value2" | (div "attr1"="value1"
^^^^^^^^^^^^^^^^ |
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_when_fewer_elements():
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (span "id"="s_id")
^ ^^^^^^^^^^^ |
! ** MISSING ** ! | (p "id"="p_id")
) | )'''
def test_i_can_see_the_diff_when_matching():
actual = Div(attr1="value1")
expected = Div(attr1=Contains("value2"))
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
debug_output = str(exc_info.value)
assert "\n" + debug_output == """
Path : 'div'
Error : The condition 'Contains(value2)' is not satisfied.
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
^^^^^^^^^^^^^^^^ |"""

326
tests/test_mytestclient.py Normal file
View File

@@ -0,0 +1,326 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement
def test_i_can_open_a_page():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return "hello world"
client.open("/")
assert client.get_content() == "hello world"
def test_i_can_open_a_page_when_html():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return Div("hello world")
client.open("/")
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
def test_i_cannot_open_a_page_not_defined():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(AssertionError) as exc_info:
client.open("/not_found")
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
def test_i_can_see_text_in_plain_response():
"""Test that should_see() works with plain text responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_see("hello world")
def test_i_can_see_text_in_html_response():
"""Test that should_see() extracts visible text from HTML responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
client.open("/").should_see("Welcome").should_see("This is a test")
def test_i_can_see_text_ignoring_html_tags():
"""Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container">Content</div>'
# Should find the visible text
client.open("/").should_see("Content")
# Should NOT find text that's only in attributes/tags
with pytest.raises(AssertionError) as exc_info:
client.should_see("container")
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
def test_i_cannot_see_text_that_is_not_present():
"""Test that should_see() raises AssertionError when text is not found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_see("goodbye")
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
assert "hello world" in str(exc_info.value)
def test_i_cannot_call_should_see_without_opening_page():
"""Test that should_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
def test_i_can_verify_text_is_not_present():
"""Test that should_not_see() works when text is absent."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_not_see("goodbye")
def test_i_cannot_use_should_not_see_when_text_is_present():
"""Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello")
error_message = str(exc_info.value)
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
def test_i_cannot_call_should_not_see_without_opening_page():
"""Test that should_not_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_not_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
def test_i_can_chain_multiple_assertions():
"""Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
# Chain multiple assertions
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
def test_i_can_see_element_context_when_text_should_not_be_seen():
"""Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p class="content">forbidden text</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("forbidden text")
error_message = str(exc_info.value)
assert "Found in:" in error_message
assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message
def test_i_can_configure_parent_levels_in_constructor():
"""Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2)
@rt('/')
def get():
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("error")
error_message = str(exc_info.value)
assert '<p>error</p>' in error_message
assert '<div class="container">' in error_message
assert '<div class="wrapper">' in error_message
def test_i_can_find_text_in_nested_elements():
"""Test that the smallest element containing the text is found in nested structures."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><section><article><p class="target">nested text</p></article></section></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("nested text")
error_message = str(exc_info.value)
# Should find the <p> element, not the outer <div>
assert '<p class="target">nested text</p>' in error_message
def test_i_can_find_fragmented_text_across_tags():
"""Test that text fragmented across multiple tags is correctly found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<p class="message">hel<span>lo</span> world</p>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello world")
error_message = str(exc_info.value)
# Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message
def test_i_do_not_find_text_in_html_attributes():
"""Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="error message" title="error info">Success</div>'
# "error" is in attributes but not in visible text
client.open("/").should_not_see("error")
# "Success" is in visible text
with pytest.raises(AssertionError):
client.should_not_see("Success")
@pytest.mark.parametrize("selector,expected_tag", [
("#unique-id", '<div class="main wrapper"'),
(".home-link", '<a class="link home-link"'),
("div > div", '<div class="content"'),
("div span", "<span"),
("[data-type]", '<a class="link home-link"'),
('[data-type="navigation"]', '<a class="link home-link"'),
('[class~="link"]', '<a class="link home-link"'),
('[href^="/home"]', '<a class="link home-link"'),
('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'),
])
def test_i_can_find_element(selector, expected_tag):
"""Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div id="unique-id" class="main wrapper">
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
<a href="/about">About</a>
<div class="content">
<span class="text">Content</span>
</div>
</div>
'''
element = client.open("/").find_element(selector)
assert element is not None
assert isinstance(element, TestableElement)
assert expected_tag in element.html_fragment
def test_i_cannot_find_element_when_none_exists():
"""Test that find_element raises AssertionError when no element matches."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element("#non-existent")
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist():
"""Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element(".text")
error_message = str(exc_info.value)
assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page():
"""Test that find_element raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.find_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()."

View File

@@ -0,0 +1,69 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, MyFT
def test_i_can_create_testable_element_from_ft():
ft = Div("hello world", id="test")
testable_element = TestableElement(None, ft)
assert testable_element.ft == ft
assert testable_element.html_fragment == '<div id="test">hello world</div>'
def test_i_can_create_testable_element_from_str():
ft = '<div id="test">hello world</div>'
testable_element = TestableElement(None, ft)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
def test_i_can_create_testable_element_from_beautifulsoup_element():
ft = '<div id="test">hello world</div>'
from bs4 import BeautifulSoup
tag = BeautifulSoup(ft, 'html.parser').div
testable_element = TestableElement(None, tag)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
def test_i_cannot_create_testable_element_from_other_type():
with pytest.raises(ValueError) as exc_info:
TestableElement(None, 123)
assert str(exc_info.value) == "Invalid source '123' for TestableElement."
def test_i_can_click():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
ft = Div(
hx_post="/search",
hx_target="#results",
hx_swap="innerHTML",
hx_vals='{"attr": "attr_value"}'
)
testable_element = TestableElement(client, ft)
@rt('/search')
def post(hx_target: str, hx_swap: str, attr: str): # hx_post is used to the Verb. It's not a parameter
return f"received {hx_target=}, {hx_swap=}, {attr=}"
testable_element.click()
assert client.get_content() == "received hx_target='#results', hx_swap='innerHTML', attr='attr_value'"
def test_i_cannot_test_when_not_clickable():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
ft = Div("hello world")
testable_element = TestableElement(client, ft)
with pytest.raises(ValueError) as exc_info:
testable_element.click()
assert str(exc_info.value) == "The <div> element has no HTMX verb attribute (e.g., hx_get, hx_post) to define a URL."