diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md index 1e8a364..c2eb46c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,37 @@ uv sync uv run bowser ``` +### Testing +Run the test suite: +```bash +# Install dev dependencies +uv sync --extra dev + +# Run all tests +uv run pytest + +# Run with verbose output +uv run pytest -v + +# Run with coverage report +uv run pytest --cov=src --cov-report=html + +# Run specific test file +uv run pytest tests/test_browser.py +``` + +### Development +```bash +# Format code +uv run black src tests + +# Lint code +uv run ruff check src tests + +# Type check +uv run mypy src +``` + ## Project Structure ``` diff --git a/pyproject.toml b/pyproject.toml index 058cf1c..db37c9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,29 @@ python_version = "3.11" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", + "--showlocals", +] + +[tool.coverage.run] +source = ["src"] +omit = ["tests/*", "*/conftest.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/src/browser/__pycache__/browser.cpython-313.pyc b/src/browser/__pycache__/browser.cpython-313.pyc index b1371b6..345016b 100644 Binary files a/src/browser/__pycache__/browser.cpython-313.pyc and b/src/browser/__pycache__/browser.cpython-313.pyc differ diff --git a/src/browser/__pycache__/chrome.cpython-313.pyc b/src/browser/__pycache__/chrome.cpython-313.pyc index 373e54f..00bccf2 100644 Binary files a/src/browser/__pycache__/chrome.cpython-313.pyc and b/src/browser/__pycache__/chrome.cpython-313.pyc differ diff --git a/src/browser/__pycache__/tab.cpython-313.pyc b/src/browser/__pycache__/tab.cpython-313.pyc index b758d24..d86817a 100644 Binary files a/src/browser/__pycache__/tab.cpython-313.pyc and b/src/browser/__pycache__/tab.cpython-313.pyc differ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2bce8dc --- /dev/null +++ b/tests/README.md @@ -0,0 +1,64 @@ +# Bowser Test Suite + +This directory contains the test suite for the Bowser browser. + +## Running Tests + +Run all tests: +```bash +uv run pytest +``` + +Run with verbose output: +```bash +uv run pytest -v +``` + +Run specific test file: +```bash +uv run pytest tests/test_browser.py +``` + +Run with coverage: +```bash +uv run pytest --cov=src --cov-report=html +``` + +View coverage report: +```bash +open htmlcov/index.html +``` + +## Test Organization + +- `test_url.py` - URL parsing and resolution +- `test_parser.py` - HTML/CSS parsing +- `test_browser.py` - Browser and tab management +- `test_cookies.py` - Cookie jar functionality +- `test_layout.py` - Layout engine components +- `test_render.py` - Rendering primitives +- `conftest.py` - Shared fixtures and configuration + +## Writing Tests + +Tests use pytest. Example: + +```python +def test_feature(): + # Arrange + obj = MyClass() + + # Act + result = obj.method() + + # Assert + assert result == expected +``` + +Use mocks for GTK components: +```python +@patch('src.browser.browser.Gtk') +def test_with_gtk(mock_gtk): + browser = Browser() + # test code +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..086a01d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Pytest configuration and fixtures.""" + +import pytest +import logging + + +@pytest.fixture(autouse=True) +def configure_logging(): + """Configure logging for tests.""" + logging.basicConfig( + level=logging.WARNING, # Only show warnings/errors in tests + format="%(name)s %(levelname)s: %(message)s", + ) + + +@pytest.fixture +def mock_browser(): + """Create a mock browser for testing.""" + from unittest.mock import Mock + browser = Mock() + browser._log = Mock() + return browser diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..863f957 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,210 @@ +"""Tests for browser tab management.""" + +import pytest +from unittest.mock import Mock, patch +from src.browser.browser import Browser +from src.browser.tab import Tab +from src.network.url import URL + + +class TestTab: + def test_tab_creation(self): + browser = Mock() + tab = Tab(browser) + assert tab.browser is browser + assert tab.current_url is None + assert tab.history == [] + assert tab.history_index == -1 + + def test_tab_title_new(self): + browser = Mock() + tab = Tab(browser) + assert tab.title == "New Tab" + + def test_tab_title_with_url(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + tab.load(URL("https://example.com")) + assert "example.com" in tab.title + + def test_tab_load_adds_history(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + url1 = URL("https://example.com") + url2 = URL("https://other.com") + + tab.load(url1) + assert len(tab.history) == 1 + assert tab.history_index == 0 + + tab.load(url2) + assert len(tab.history) == 2 + assert tab.history_index == 1 + + def test_tab_go_back(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + url1 = URL("https://example.com") + url2 = URL("https://other.com") + + tab.load(url1) + tab.load(url2) + + result = tab.go_back() + assert result is True + assert tab.history_index == 0 + + def test_tab_go_back_at_start(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + tab.load(URL("https://example.com")) + + result = tab.go_back() + assert result is False + assert tab.history_index == 0 + + def test_tab_go_forward(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + + tab.load(URL("https://example.com")) + tab.load(URL("https://other.com")) + tab.go_back() + + result = tab.go_forward() + assert result is True + assert tab.history_index == 1 + + def test_tab_go_forward_at_end(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + tab.load(URL("https://example.com")) + + result = tab.go_forward() + assert result is False + + def test_tab_reload(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + tab.load(URL("https://example.com")) + + result = tab.reload() + assert result is True + assert tab.history_index == 0 + + def test_tab_history_truncation(self): + browser = Mock() + browser._log = Mock() + tab = Tab(browser) + + tab.load(URL("https://example.com")) + tab.load(URL("https://other.com")) + tab.load(URL("https://third.com")) + tab.go_back() # now at other.com + tab.load(URL("https://new.com")) # should truncate third.com + + assert len(tab.history) == 3 + assert tab.history_index == 2 + + +@patch('src.browser.browser.Gtk') +class TestBrowser: + def test_browser_creation(self, mock_gtk): + browser = Browser() + assert browser.tabs == [] + assert browser.active_tab is None + + def test_new_tab(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + + tab = browser.new_tab("https://example.com") + + assert len(browser.tabs) == 1 + assert browser.active_tab is tab + assert tab in browser.tabs + + def test_set_active_tab(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + browser.chrome.paint = Mock() + browser.chrome.tabs_box = Mock() + + tab1 = browser.new_tab("https://example.com") + tab2 = browser.new_tab("https://other.com") + + browser.set_active_tab(tab1) + assert browser.active_tab is tab1 + + def test_close_tab(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + browser.chrome.paint = Mock() + browser.chrome.tabs_box = Mock() + + tab1 = browser.new_tab("https://example.com") + tab2 = browser.new_tab("https://other.com") + + browser.close_tab(tab1) + + assert len(browser.tabs) == 1 + assert tab1 not in browser.tabs + assert browser.active_tab is tab2 + + def test_close_active_tab_selects_previous(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + browser.chrome.paint = Mock() + browser.chrome.tabs_box = Mock() + + tab1 = browser.new_tab("https://example.com") + tab2 = browser.new_tab("https://other.com") + tab3 = browser.new_tab("https://third.com") + + browser.close_tab(tab3) + assert browser.active_tab is tab2 + + def test_close_last_tab(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + browser.chrome.paint = Mock() + browser.chrome.tabs_box = Mock() + + tab = browser.new_tab("https://example.com") + browser.close_tab(tab) + + assert len(browser.tabs) == 0 + assert browser.active_tab is None + + def test_navigate_to(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + browser.chrome.paint = Mock() + + tab = browser.new_tab("https://example.com") + browser.navigate_to("https://other.com") + + assert len(tab.history) == 2 + + def test_navigate_to_no_active_tab(self, mock_gtk): + browser = Browser() + browser.chrome.rebuild_tab_bar = Mock() + browser.chrome.update_address_bar = Mock() + + browser.navigate_to("https://example.com") + + assert len(browser.tabs) == 1 + assert browser.active_tab is not None diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..eb4bda0 --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,41 @@ +"""Tests for cookie management.""" + +import pytest +from src.network.cookies import CookieJar + + +class TestCookieJar: + def test_cookie_jar_creation(self): + jar = CookieJar() + assert jar._cookies == {} + + def test_set_cookies(self): + jar = CookieJar() + jar.set_cookies("https://example.com", "session=abc123") + + cookies = jar.get_cookie_header("https://example.com") + assert "session=abc123" in cookies + + def test_get_cookie_header_empty(self): + jar = CookieJar() + cookies = jar.get_cookie_header("https://example.com") + assert cookies == "" + + def test_multiple_cookies_same_origin(self): + jar = CookieJar() + jar.set_cookies("https://example.com", "session=abc123") + jar.set_cookies("https://example.com", "user=john") + + cookies = jar.get_cookie_header("https://example.com") + assert "session=abc123" in cookies or "user=john" in cookies + + def test_cookies_isolated_by_origin(self): + jar = CookieJar() + jar.set_cookies("https://example.com", "session=abc123") + jar.set_cookies("https://other.com", "session=xyz789") + + cookies1 = jar.get_cookie_header("https://example.com") + cookies2 = jar.get_cookie_header("https://other.com") + + assert "abc123" in cookies1 + assert "xyz789" in cookies2 diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..4960115 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,58 @@ +"""Tests for layout components.""" + +import pytest +from unittest.mock import Mock +from src.layout.document import DocumentLayout +from src.layout.block import BlockLayout, LineLayout +from src.layout.inline import TextLayout +from src.parser.html import Element, Text + + +class TestDocumentLayout: + def test_document_layout_creation(self): + node = Element("html") + layout = DocumentLayout(node) + assert layout.node is node + assert layout.children == [] + + def test_document_layout(self): + node = Element("html") + layout = DocumentLayout(node) + result = layout.layout(800, 1.0) + assert result == 800.0 + + +class TestBlockLayout: + def test_block_layout_creation(self): + node = Element("div") + layout = BlockLayout(node) + assert layout.node is node + assert layout.children == [] + + def test_block_layout_with_parent(self): + parent_node = Element("body") + child_node = Element("div") + parent_layout = BlockLayout(parent_node) + child_layout = BlockLayout(child_node, parent=parent_layout) + assert child_layout.parent is parent_layout + + +class TestLineLayout: + def test_line_layout_creation(self): + node = Element("span") + layout = LineLayout(node) + assert layout.node is node + + +class TestTextLayout: + def test_text_layout_creation(self): + node = Text("Hello") + layout = TextLayout(node, "Hello") + assert layout.node is node + assert layout.word == "Hello" + + def test_text_layout_length(self): + node = Text("Hello") + layout = TextLayout(node, "Hello") + result = layout.layout() + assert result == 5 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..c071e91 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,52 @@ +"""Tests for HTML parsing.""" + +import pytest +from src.parser.html import Text, Element, print_tree + + +class TestHTMLElements: + def test_text_node(self): + text = Text("Hello World") + assert text.text == "Hello World" + assert text.parent is None + + def test_text_node_with_parent(self): + parent = Element("div") + text = Text("Hello", parent=parent) + assert text.parent is parent + + def test_element_node(self): + elem = Element("div", {"class": "container"}) + assert elem.tag == "div" + assert elem.attributes == {"class": "container"} + assert elem.children == [] + + def test_element_default_attributes(self): + elem = Element("p") + assert elem.attributes == {} + + def test_element_parent(self): + parent = Element("body") + child = Element("div", parent=parent) + assert child.parent is parent + + +class TestPrintTree: + def test_print_single_element(self, capsys): + elem = Element("div") + print_tree(elem) + captured = capsys.readouterr() + assert "Element('div'" in captured.out + + def test_print_tree_with_children(self, capsys): + root = Element("html") + body = Element("body", parent=root) + text = Text("Hello", parent=body) + root.children = [body] + body.children = [text] + + print_tree(root) + captured = capsys.readouterr() + assert "Element('html'" in captured.out + assert "Element('body'" in captured.out + assert "Text('Hello')" in captured.out diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..e59e515 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,55 @@ +"""Tests for rendering primitives.""" + +import pytest +from src.render.paint import PaintCommand, DrawText +from src.render.composite import CompositedLayer +from src.render.fonts import get_font, linespace + + +class TestPaintCommands: + def test_paint_command_creation(self): + cmd = PaintCommand((0, 0, 100, 100)) + assert cmd.rect == (0, 0, 100, 100) + + def test_draw_text_creation(self): + cmd = DrawText(10, 20, "Hello", ("Arial", 12), "black") + assert cmd.text == "Hello" + assert cmd.font == ("Arial", 12) + assert cmd.color == "black" + + +class TestCompositedLayer: + def test_composited_layer_creation(self): + layer = CompositedLayer() + assert layer.items == [] + + def test_composited_layer_with_item(self): + item = "mock_item" + layer = CompositedLayer(item) + assert len(layer.items) == 1 + assert layer.items[0] == item + + def test_add_item(self): + layer = CompositedLayer() + layer.add("item1") + layer.add("item2") + assert len(layer.items) == 2 + + +class TestFonts: + def test_get_font(self): + font = get_font(14) + assert font == (14, "normal", "normal") + + def test_get_font_with_weight(self): + font = get_font(16, weight="bold") + assert font == (16, "bold", "normal") + + def test_get_font_with_style(self): + font = get_font(12, style="italic") + assert font == (12, "normal", "italic") + + def test_linespace(self): + font = (14, "normal", "normal") + space = linespace(font) + assert space == int(14 * 1.2) diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 0000000..fd0dc95 --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,41 @@ +"""Tests for URL parsing and resolution.""" + +import pytest +from src.network.url import URL + + +class TestURL: + def test_parse_simple_url(self): + url = URL("https://example.com") + assert str(url) == "https://example.com" + + def test_parse_url_with_path(self): + url = URL("https://example.com/path/to/page") + assert str(url) == "https://example.com/path/to/page" + + def test_parse_url_with_query(self): + url = URL("https://example.com/search?q=test") + assert str(url) == "https://example.com/search?q=test" + + def test_origin(self): + url = URL("https://example.com:8080/path") + assert url.origin() == "https://example.com:8080" + + def test_origin_default_port(self): + url = URL("https://example.com/path") + assert url.origin() == "https://example.com" + + def test_resolve_relative_path(self): + base = URL("https://example.com/dir/page.html") + resolved = base.resolve("other.html") + assert str(resolved) == "https://example.com/dir/other.html" + + def test_resolve_absolute_path(self): + base = URL("https://example.com/dir/page.html") + resolved = base.resolve("/root/page.html") + assert str(resolved) == "https://example.com/root/page.html" + + def test_resolve_full_url(self): + base = URL("https://example.com/page.html") + resolved = base.resolve("https://other.com/page.html") + assert str(resolved) == "https://other.com/page.html"