mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
Add comprehensive test suite with pytest
- Add tests for URL parsing, cookies, HTML/CSS parsing - Add tests for browser/tab management and history - Add tests for layout and rendering components - Configure pytest with coverage reporting - Add test documentation and runner commands - All 54 tests passing
This commit is contained in:
parent
f1e4957e70
commit
ae6fcbfab4
14 changed files with 601 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
__pycache__/
|
||||
31
README.md
31
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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
]
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
64
tests/README.md
Normal file
64
tests/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal file
|
|
@ -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
|
||||
210
tests/test_browser.py
Normal file
210
tests/test_browser.py
Normal file
|
|
@ -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
|
||||
41
tests/test_cookies.py
Normal file
41
tests/test_cookies.py
Normal file
|
|
@ -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
|
||||
58
tests/test_layout.py
Normal file
58
tests/test_layout.py
Normal file
|
|
@ -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
|
||||
52
tests/test_parser.py
Normal file
52
tests/test_parser.py
Normal file
|
|
@ -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
|
||||
55
tests/test_render.py
Normal file
55
tests/test_render.py
Normal file
|
|
@ -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)
|
||||
41
tests/test_url.py
Normal file
41
tests/test_url.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue