nested text
Content
First
Second
Third
Content
Content
First
Second
Third
diff --git a/Makefile b/Makefile index d4147ca..19b2637 100644 --- a/Makefile +++ b/Makefile @@ -14,5 +14,9 @@ clean-build: clean-package find . -name "*.pyc" -exec rm -f {} + find . -name "*.pyo" -exec rm -f {} + +clean-tests: + rm -rf tests/.sesskey + rm -rf tests/Users.db + # Alias to clean everything -clean: clean-build \ No newline at end of file +clean: clean-build clean-tests \ No newline at end of file diff --git a/src/myfasthtml/auth/routes.py b/src/myfasthtml/auth/routes.py index 1e19432..31ec983 100644 --- a/src/myfasthtml/auth/routes.py +++ b/src/myfasthtml/auth/routes.py @@ -18,7 +18,7 @@ from ..auth.utils import ( ) -def setup_auth_routes(app, rt, mount_auth_app=True): +def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"): """ Setup all authentication and protected routes. @@ -26,6 +26,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True): app: FastHTML application instance rt: Route decorator from FastHTML mount_auth_app: Whether to mount the auth FastApi API routes + sqlite_db_path: by default, create a new SQLite database at this path """ # ============================================================================ @@ -187,7 +188,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True): def mount_auth_fastapi_api(): # Mount FastAPI auth backend - auth_api = create_auth_app_for_sqlite("Users.db", "jwt-secret-to-change") + auth_api = create_auth_app_for_sqlite(sqlite_db_path, "jwt-secret-to-change") app.mount("/auth", auth_api) if mount_auth_app: diff --git a/src/myfasthtml/auth/utils.py b/src/myfasthtml/auth/utils.py index 45b724e..5567995 100644 --- a/src/myfasthtml/auth/utils.py +++ b/src/myfasthtml/auth/utils.py @@ -27,8 +27,10 @@ DEFAULT_SKIP_PATTERNS = [ r'.*\.css', r'.*\.js', '/login', - '/login2', + '/login-p', '/register', + '/register-p', + '/logout', ] diff --git a/src/myfasthtml/core/testclient.py b/src/myfasthtml/core/testclient.py index bed8113..9d94a0c 100644 --- a/src/myfasthtml/core/testclient.py +++ b/src/myfasthtml/core/testclient.py @@ -2,6 +2,7 @@ import dataclasses import json import uuid from dataclasses import dataclass +from typing import Self from bs4 import BeautifulSoup, Tag from fastcore.xml import FT, to_xml @@ -132,7 +133,10 @@ class TestableElement: def _support_htmx(self): """Check if the element supports HTMX.""" - return self.ft.has_attr('hx_get') or self.ft.has_attr('hx_post') + return ('hx_get' in self.ft.attrs or + 'hx-get' in self.ft.attrs or + 'hx_post' in self.ft.attrs or + 'hx-post' in self.ft.attrs) class TestableForm(TestableElement): @@ -149,7 +153,7 @@ class TestableForm(TestableElement): source: The source HTML string containing a form. """ super().__init__(client, source) - self.form = BeautifulSoup(source, 'html.parser').find('form') + self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form') self.fields_mapping = {} # link between the input label and the input name self.fields = {} # field name; field value self.select_fields = {} # list of possible options for 'select' input fields @@ -236,7 +240,7 @@ class TestableForm(TestableElement): elif options: self.fields[name] = options[0]['value'] - def fill_form(self, **kwargs): + def fill(self, **kwargs): """ Fill the form with the given data. @@ -244,16 +248,55 @@ class TestableForm(TestableElement): **kwargs: Field names and their values to fill in the form. """ for name, value in kwargs.items(): - self.fields[name] = value + field_name = self.translate(name) + if field_name not in self.fields: + raise ValueError(f"Invalid field name '{name}'.") + self.fields[self.translate(name)] = value def submit(self): """ Submit the form. + This method handles both HTMX-enabled forms and classic HTML form submissions: + - If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request + - Otherwise, simulates a classic browser form submission using the form's + action and method attributes + Returns: The response from the form submission. + + Raises: + ValueError: If the form has no action attribute for classic submission. """ - return self._send_htmx_request(data=self.fields) + # Check if the form supports HTMX + if self._support_htmx(): + return self._send_htmx_request(data=self.fields) + + # Classic form submission + action = self.form.get('action') + if not action or action.strip() == '': + raise ValueError( + "The form has no 'action' attribute. " + "Cannot submit a classic form without a target URL." + ) + + method = self.form.get('method', 'post').upper() + + # Prepare headers for classic form submission + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + # Send the request via the client + return self.client.send_request( + method=method, + url=action, + headers=headers, + data=self.fields + ) + + def translate(self, field): + return self.fields_mapping.get(field, field) def _update_fields_mapping(self): """ @@ -459,7 +502,7 @@ class MyTestClient: # make sure that the commands are mounted mount_commands(self.app) - def open(self, path: str): + def open(self, path: str) -> Self: """ Open a page and store its content for subsequent assertions. @@ -483,7 +526,9 @@ class MyTestClient: return self def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None): - json_data['session'] = self._session + if json_data is not None: + json_data['session'] = self._session + res = self.client.request( method, url, @@ -500,7 +545,7 @@ class MyTestClient: self.set_content(res.text) return self - def should_see(self, text: str): + def should_see(self, text: str) -> Self: """ Assert that the given text is present in the visible page content. @@ -517,6 +562,11 @@ class MyTestClient: AssertionError: If the text is not found in the page content. ValueError: If no page has been opened yet. """ + + def clean_text(txt): + return "\n".join(line for line in txt.splitlines() if line.strip()) + + if self._content is None: raise ValueError( "No page content available. Call open() before should_see()." @@ -527,7 +577,7 @@ class MyTestClient: if text not in visible_text: # Provide a snippet of the actual content for debugging snippet_length = 200 - content_snippet = ( + content_snippet = clean_text( visible_text[:snippet_length] + "..." if len(visible_text) > snippet_length else visible_text @@ -539,7 +589,7 @@ class MyTestClient: return self - def should_not_see(self, text: str): + def should_not_see(self, text: str) -> Self: """ Assert that the given text is NOT present in the visible page content. @@ -582,7 +632,7 @@ class MyTestClient: return self - def find_element(self, selector: str): + def find_element(self, selector: str) -> TestableElement: """ Find a single HTML element using a CSS selector. @@ -621,7 +671,7 @@ class MyTestClient: f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." ) - def find_form(self, fields: list = None): + def find_form(self, fields: list = None) -> TestableForm: """ Find a form element in the page content. Can provide title of the fields to ease the search @@ -630,22 +680,29 @@ class MyTestClient: """ if self._content is None: raise ValueError( - "No page content available. Call open() before find_element()." + "No page content available. Call open() before find_form()." ) results = self._soup.select("form") if len(results) == 0: raise AssertionError( - f"No element form found." + f"No form found." ) - # result = _filter(results, fields) + if fields is None: + remaining = [TestableForm(self, form) for form in results] + else: + remaining = [] + for form in results: + testable_form = TestableForm(self, form) + if all(testable_form.translate(field) in testable_form.fields for field in fields): + remaining.append(testable_form) - if len(results) == 1: - return TestableForm(self, results[0]) + if len(remaining) == 1: + return remaining[0] else: raise AssertionError( - f"Found {len(results)} forms (with the specified fields). Expected exactly 1." + f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1." ) def get_content(self) -> str: @@ -657,7 +714,7 @@ class MyTestClient: """ return self._content - def set_content(self, content: str): + def set_content(self, content: str) -> Self: """ Set the HTML content and parse it with BeautifulSoup. @@ -666,6 +723,7 @@ class MyTestClient: """ self._content = content self._soup = BeautifulSoup(content, 'html.parser') + return self @staticmethod def _find_visible_text_element(soup, text: str): diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py index 24898ea..0017f54 100644 --- a/tests/auth/test_login.py +++ b/tests/auth/test_login.py @@ -1,8 +1,10 @@ +import os + import pytest from fasthtml.fastapp import fast_app from myfasthtml.auth.routes import setup_auth_routes -from myfasthtml.auth.utils import create_auth_beforeware +from myfasthtml.auth.utils import create_auth_beforeware, register_user from myfasthtml.core.testclient import MyTestClient @@ -10,7 +12,7 @@ from myfasthtml.core.testclient import MyTestClient def app(): beforeware = create_auth_beforeware() test_app, test_rt = fast_app(before=beforeware) - setup_auth_routes(test_app, test_rt) + setup_auth_routes(test_app, test_rt, mount_auth_app=True, sqlite_db_path="TestUsers.db") return test_app @@ -24,16 +26,34 @@ def user(app): user = MyTestClient(app) return user + +@pytest.fixture(autouse=True) +def cleanup(): + if os.path.exists("TestUsers.db"): + os.remove("TestUsers.db") + + def test_i_can_see_login_page(user): user.open("/login") user.should_see("Sign In") user.should_see("Register here") - + user.find_form(fields=["Email", "Password"]) + + def test_i_cannot_login_with_wrong_credentials(user): user.open("/login") - user.fill_form({ - "username": "wrong", - "password": "" - }) - user.click("button") - user.should_see("Invalid credentials") \ No newline at end of file + form = user.find_form(fields=["Email", "Password"]) + form.fill(Email="user@email.com", Password="#Passw0rd") + form.submit() + user.should_see("Invalid email or password. Please try again.") + + +def test_i_can_login_with_correct_credentials(user): + # create user + register_user("user@email.com", "user", "#Passw0rd") + + user.open("/login") + form = user.find_form(fields=["Email", "Password"]) + form.fill(Email="user@email.com", Password="#Passw0rd") + form.submit() + user.should_see("You are now logged in") diff --git a/tests/testclient/test_mytestclient.py b/tests/testclient/test_mytestclient.py index 1815a48..19a4707 100644 --- a/tests/testclient/test_mytestclient.py +++ b/tests/testclient/test_mytestclient.py @@ -2,43 +2,45 @@ import pytest from fasthtml.components import Div from fasthtml.fastapp import fast_app -from myfasthtml.core.testclient import MyTestClient, TestableElement +from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm -def test_i_can_open_a_page(): - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) +class TestMyTestClientOpen: - @rt('/') - def get(): return "hello world" + def test_i_can_open_a_page(self): + 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" - client.open("/") + def test_i_can_open_a_page_when_html(self): + 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() == ' \n \n
\nThis is a test
" + def test_i_can_see_text_in_html_response(self): + """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 "This is a test
" + + client.open("/").should_see("Welcome").should_see("This is a test") - 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) + def test_i_can_see_text_ignoring_html_tags(self): + """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 'Content here
" + + # Chain multiple assertions + client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error") - 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) + def test_i_can_see_element_context_when_text_should_not_be_seen(self): + """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 'forbidden text
forbidden text
' in error_message + assert 'error
error
' in error_message + assert 'nested text
element, not the outer
nested text
' in error_message - @rt('/') - def get(): - return "hello world" + def test_i_can_find_fragmented_text_across_tags(self): + """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 '' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_not_see("hello world") + + error_message = str(exc_info.value) + # Should find the parentelement that contains the full text + assert '
Content here
" - - # 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 'forbidden text
forbidden text
' in error_message - assert 'error
error
' in error_message - assert 'nested text
element, not the outer
nested text
' 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 '' - - with pytest.raises(AssertionError) as exc_info: - client.open("/").should_not_see("hello world") - - error_message = str(exc_info.value) - # Should find the parentelement that contains the full text - assert '
' - - # "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", 'Content
First
Second
Third
Content
Content
First
Second
Third