Refactor test files to remove unnecessary imports and improve readability

- Removed unused imports from various test files to streamline code.
- Cleaned up whitespace in test cases for better consistency.
- Updated dependency management in `uv.lock` to reflect changes in optional dependencies.
- Ensured all tests maintain functionality while enhancing clarity and organization.
This commit is contained in:
Benedikt Willi 2026-01-12 10:22:34 +01:00
parent 4b3ba9144d
commit c9ef5e5c44
29 changed files with 989 additions and 625 deletions

345
.github/skills/skills.md vendored Normal file
View file

@ -0,0 +1,345 @@
# Skills Index
This file defines **canonical project skills** the LLM must follow. Each skill specifies the *one correct command* for a common project action.
1. **Run the Project**`uv run bowser`
2. **Test the Project**`uv run pytest`
3. **Lint the Project**`uv run ruff`
Deviating from these commands is considered incorrect behavior unless explicitly instructed.
---
# Skill: Run the Project with `uv run bowser`
## Purpose
Teach the LLM **how and when to run this project** using the canonical command:
```bash
uv run bowser
```
This skill ensures the LLM consistently uses the correct entry point, avoids adhoc commands, and follows project conventions.
---
## Canonical Command
**Always run the project using:**
```bash
uv run bowser
```
Do **not**:
- Call `python` directly
- Run scripts via file paths
- Use alternative task runners (e.g. `make`, `poetry run`, `pipenv run`)
`uv` is the authoritative environment and dependency manager for this project, and `bowser` is the defined runtime entry point.
---
## What `uv run bowser` Means
- `uv run`:
- Ensures dependencies are resolved and installed according to the project configuration
- Executes commands inside the correct, isolated environment
- `bowser`:
- The projects primary executable / CLI
- Encapsulates startup logic, configuration loading, and runtime behavior
Together, they guarantee a reproducible and correct execution environment.
---
## When to Use This Command
Use `uv run bowser` whenever you need to:
- Start the application
- Run the main service or agent
- Execute project logic endtoend
- Validate runtime behavior
- Demonstrate how the project is launched
If the task says **“run the project”**, **“start the app”**, or **“execute Bowser”**, this is the command.
---
## When *Not* to Use This Command
Do **not** use `uv run bowser` when:
- Running tests (use the projects test command instead)
- Running oneoff scripts unless explicitly routed through `bowser`
- Installing dependencies
- Linting or formatting code
If unsure, default to **not running anything** and explain what would be executed.
---
## How to Explain This to Humans
When documenting or instructing users, say:
> “Run the project with `uv run bowser`.”
Optionally add:
> “This ensures the correct environment and entry point are used.”
Do **not** overexplain unless the user asks.
---
## Error Handling Guidance
If `uv run bowser` fails:
1. Assume a dependency or configuration issue
2. Report the error output verbatim
3. Do **not** substitute another execution method
4. Suggest fixing the root cause, not changing the command
---
## Mental Model for the LLM
- There is **one** correct way to run the project
- That way is **stable and intentional**
- Deviating from it is a bug
Think of `uv run bowser` as:
> “The projects onswitch.”
---
## Summary (Checklist)
Before suggesting how to run the project, verify:
- [ ] You are using `uv run`
- [ ] You are invoking `bowser`
- [ ] You are not calling Python directly
- [ ] You are not inventing alternate commands
If all are true, you are doing it right.
---
# Skill: Test the Project with `uv run pytest`
## Purpose
Teach the LLM **how and when to run tests** for this project using the canonical command:
```bash
uv run pytest
```
This skill ensures tests are executed in the correct environment, using the projects standard tooling, without inventing alternate commands.
---
## Canonical Command
**Always run tests using:**
```bash
uv run pytest
```
Do **not**:
- Call `pytest` directly
- Use `python -m pytest`
- Run tests via ad-hoc scripts or task runners
`uv` is the authoritative environment manager, and `pytest` is the test runner for this project.
---
## What `uv run pytest` Means
- `uv run`:
- Ensures dependencies (including test dependencies) are resolved correctly
- Runs inside the same environment model as the application
- `pytest`:
- Discovers and runs the projects test suite
- Applies project-level configuration (e.g. `pytest.ini`, `pyproject.toml`)
Together, they guarantee consistent and reproducible test execution.
---
## When to Use This Command
Use `uv run pytest` whenever you need to:
- Run the full test suite
- Verify a change before or after modifying code
- Reproduce a failing test
- Validate behavior without starting the application
If the task says **“run tests”**, **“test the project”**, or **“verify with pytest”**, this is the command.
---
## When *Not* to Use This Command
Do **not** use `uv run pytest` when:
- Running the application (use `uv run bowser`)
- Installing dependencies
- Linting or formatting code
- Executing non-test scripts
If unsure, default to explaining what tests would be run rather than executing them.
---
## Error Handling Guidance
If `uv run pytest` fails:
1. Capture and report the full pytest output
2. Identify whether the failure is:
- A test assertion failure
- A missing dependency or import error
- A configuration issue
3. Do **not** change the command to work around the failure
4. Fix the underlying cause, then re-run the same command
---
## Mental Model for the LLM
- There is **one** correct way to run tests
- Test execution should mirror the real runtime environment
- Consistency matters more than convenience
Think of `uv run pytest` as:
> “The projects truth-check.”
---
## Summary (Checklist)
Before suggesting how to test the project, verify:
- [ ] You are using `uv run`
- [ ] You are invoking `pytest`
- [ ] You are not calling Python directly
- [ ] You are not inventing alternate test commands
If all are true, you are doing it right.
---
# Skill: Lint the Project with `uv run ruff`
## Purpose
Teach the LLM **how and when to lint the project** using the canonical command:
```bash
uv run ruff
```
This skill ensures linting is performed consistently, using the projects configured rules and environment.
---
## Canonical Command
**Always lint the project using:**
```bash
uv run ruff
```
Do **not**:
- Call `ruff` directly
- Use alternative linters unless explicitly instructed
- Invoke formatting or linting via ad-hoc scripts
`uv` guarantees the correct environment, and `ruff` enforces the projects linting standards.
---
## What `uv run ruff` Means
- `uv run`:
- Executes linting inside the managed project environment
- Ensures the correct version of `ruff` and dependencies are used
- `ruff`:
- Performs fast, opinionated linting
- Applies rules configured in project files (e.g. `pyproject.toml`)
Together, they provide deterministic and repeatable lint results.
---
## When to Use This Command
Use `uv run ruff` whenever you need to:
- Check code quality
- Identify linting or style issues
- Validate changes before committing
- Respond to a request to “lint the project” or “run ruff”
---
## When *Not* to Use This Command
Do **not** use `uv run ruff` when:
- Running the application (`uv run bowser`)
- Running tests (`uv run pytest`)
- Formatting code unless `ruff` is explicitly configured to do so
- Installing dependencies
If unsure, explain what linting would check instead of executing it.
---
## Error Handling Guidance
If `uv run ruff` reports issues:
1. Treat findings as authoritative
2. Report errors or warnings clearly
3. Do **not** suppress or bypass lint rules
4. Fix the code, then re-run the same command
---
## Mental Model for the LLM
- Linting enforces shared standards
- Speed and consistency matter more than flexibility
- There is **one** correct linting command
Think of `uv run ruff` as:
> “The projects code-quality gate.”
---
## Summary (Checklist)
Before suggesting how to lint the project, verify:
- [ ] You are using `uv run`
- [ ] You are invoking `ruff`
- [ ] You are not inventing alternate linting tools
If all are true, you are doing it right.

View file

@ -32,11 +32,11 @@ def main():
_configure_logging(args)
browser = Browser()
# Enable debug mode in chrome if --debug flag is set
if args.debug:
browser.chrome.debug_mode = True
# If no URL provided, use startpage
url = args.url if args.url else "about:startpage"
browser.new_tab(url)

View file

@ -16,7 +16,7 @@ dependencies = [
"Jinja2>=3.0", # Template engine for pages
]
[project.optional-dependencies]
[dependency-groups]
dev = [
"pytest>=9.0.0",
"pytest-cov>=7.0.0",
@ -35,12 +35,14 @@ managed = true
packages = ["src"]
[tool.black]
line-length = 100
line-length = 120
target-version = ["py311"]
[tool.ruff]
line-length = 100
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W"]
[tool.mypy]

View file

@ -1,3 +1,4 @@
# ruff: noqa: E402
"""Browser entry and orchestration."""
import gi

View file

@ -1,3 +1,4 @@
# ruff: noqa: E402
"""Browser chrome (Adwaita UI)."""
import gi
@ -34,38 +35,38 @@ class Chrome:
self.skia_surface: Optional[skia.Surface] = None
self.tab_pages: dict = {} # Map tab objects to AdwTabPage
self._closing_tabs: set = set() # Track tabs being closed to prevent re-entry
# Render pipeline - handles layout and painting
self.render_pipeline = RenderPipeline()
# Debug mode state
self.debug_mode = False
# FPS tracking for debug mode
self.frame_times = [] # List of recent frame timestamps
self.fps = 0.0
# Profiling data
self._last_profile = {}
self._last_profile_total = 0.0
# Scroll state
self.scroll_y = 0
self.document_height = 0 # Total document height for scroll limits
self.viewport_height = 0 # Current viewport height
# Scrollbar fade state
self.scrollbar_opacity = 0.0
self.scrollbar_fade_timeout = None
# Selection state
self.selection_start = None # (x, y) of selection start
self.selection_end = None # (x, y) of selection end
self.is_selecting = False # True while mouse is dragging
# Layout information for text selection (populated from render pipeline)
self.text_layout = []
# Sub-timings for detailed profiling
self._render_sub_timings = {}
self._visible_line_count = 0
@ -76,7 +77,7 @@ class Chrome:
if not hasattr(self.browser.app, '_adw_init'):
Adw.init()
self.browser.app._adw_init = True
# Create Adwaita window instead of standard GTK window
self.window = Adw.ApplicationWindow(application=self.browser.app)
self.window.set_default_size(1024, 768)
@ -89,30 +90,30 @@ class Chrome:
# Header bar with navigation and address bar
header_bar = Gtk.HeaderBar()
vbox.append(header_bar)
# Navigation buttons in header bar
nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
nav_box.add_css_class("linked")
self.back_btn = Gtk.Button(label="")
self.back_btn.set_tooltip_text("Back")
self.forward_btn = Gtk.Button(label="")
self.forward_btn.set_tooltip_text("Forward")
self.reload_btn = Gtk.Button(label="")
self.reload_btn.set_tooltip_text("Reload")
nav_box.append(self.back_btn)
nav_box.append(self.forward_btn)
nav_box.append(self.reload_btn)
header_bar.pack_start(nav_box)
# Address bar - centered in header
self.address_bar = Gtk.Entry()
self.address_bar.set_placeholder_text("Enter URL...")
self.address_bar.set_hexpand(True)
self.address_bar.set_max_width_chars(40)
header_bar.set_title_widget(self.address_bar)
# Go button in header bar end
self.go_btn = Gtk.Button(label="Go")
self.go_btn.add_css_class("suggested-action")
@ -120,26 +121,26 @@ class Chrome:
# Create TabView for managing tabs
self.tab_view = Adw.TabView()
# Create TabBar for tab display
self.tab_bar = Adw.TabBar()
self.tab_bar.set_view(self.tab_view)
self.tab_bar.set_autohide(False)
# Add New Tab button to the tab bar
new_tab_btn = Gtk.Button()
new_tab_btn.set_icon_name("list-add-symbolic")
new_tab_btn.set_tooltip_text("New Tab")
new_tab_btn.connect("clicked", lambda _: self.browser.new_tab("about:startpage"))
self.tab_bar.set_end_action_widget(new_tab_btn)
vbox.append(self.tab_bar)
# Create a container box for content that will hold the drawing area
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
content_box.set_vexpand(True)
content_box.set_hexpand(True)
# Create the drawing area for rendering page content
self.drawing_area = Gtk.DrawingArea()
self.drawing_area.set_vexpand(True)
@ -148,25 +149,25 @@ class Chrome:
self.drawing_area.set_can_focus(True) # Allow focus for keyboard events
self.drawing_area.set_focusable(True)
content_box.append(self.drawing_area)
# Add scroll controller for mouse wheel
scroll_controller = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL
)
scroll_controller.connect("scroll", self._on_scroll)
self.drawing_area.add_controller(scroll_controller)
# Add mouse button controller for selection
click_controller = Gtk.GestureClick.new()
click_controller.connect("pressed", self._on_mouse_pressed)
click_controller.connect("released", self._on_mouse_released)
self.drawing_area.add_controller(click_controller)
# Add motion controller for drag selection
motion_controller = Gtk.EventControllerMotion.new()
motion_controller.connect("motion", self._on_mouse_motion)
self.drawing_area.add_controller(motion_controller)
# Add content box to vbox (not to TabView - we use a single drawing area for all tabs)
vbox.append(content_box)
@ -192,59 +193,59 @@ class Chrome:
self.forward_btn.connect("clicked", lambda _b: self.browser.go_forward())
if self.reload_btn:
self.reload_btn.connect("clicked", lambda _b: self.browser.reload())
# Connect TabView signals
if self.tab_view:
self.tab_view.connect("page-attached", self._on_page_attached)
self.tab_view.connect("close-page", self._on_close_page)
self.tab_view.connect("notify::selected-page", self._on_selected_page_changed)
# Setup keyboard shortcuts
self._setup_keyboard_shortcuts()
# Add any tabs that were created before the window
for tab in self.browser.tabs:
if tab not in self.tab_pages:
self._add_tab_to_ui(tab)
# Set the active tab in the UI
if self.browser.active_tab and self.browser.active_tab in self.tab_pages:
page = self.tab_pages[self.browser.active_tab]
self.tab_view.set_selected_page(page)
# Show the window
self.window.present()
def _add_tab_to_ui(self, tab):
"""Internal method to add a tab to the TabView UI."""
# Create a simple placeholder for the tab
# (Actual rendering happens in the shared drawing_area)
placeholder = Gtk.Box()
# Create the tab page
page = self.tab_view.append(placeholder)
page.set_title(tab.title)
# Store mapping
self.tab_pages[tab] = page
# Select this tab
self.tab_view.set_selected_page(page)
def add_tab(self, tab):
"""Add a tab to the TabView."""
if not self.tab_view:
# Window not created yet, tab will be added when window is created
return
self._add_tab_to_ui(tab)
def update_tab(self, tab):
"""Update tab title and other properties."""
if tab in self.tab_pages:
page = self.tab_pages[tab]
page.set_title(tab.title)
def remove_tab(self, tab):
"""Remove a tab from the TabView programmatically."""
if tab not in self.tab_pages:
@ -253,20 +254,20 @@ class Chrome:
del self.tab_pages[tab]
# Directly close the page - this triggers _on_close_page but we've already removed from tab_pages
self.tab_view.close_page(page)
def set_active_tab(self, tab):
"""Set the active tab in the TabView."""
if tab in self.tab_pages:
page = self.tab_pages[tab]
self.tab_view.set_selected_page(page)
def _on_page_attached(self, tab_view, page, position):
"""Handle when a page is attached to the TabView."""
self.logger.debug(f"Page attached at position {position}")
def _on_close_page(self, tab_view, page):
"""Handle tab close request from UI.
This is called when close_page() is invoked. We must call close_page_finish()
to actually complete the page removal.
"""
@ -276,7 +277,7 @@ class Chrome:
if tab_page == page:
tab_to_close = tab
break
if tab_to_close:
# Remove from our tracking
del self.tab_pages[tab_to_close]
@ -289,7 +290,7 @@ class Chrome:
# Page not in our tracking - just confirm the close
self.tab_view.close_page_finish(page, True)
return True
def _on_selected_page_changed(self, tab_view, pspec):
"""Handle tab selection change."""
selected_page = tab_view.get_selected_page()
@ -321,7 +322,7 @@ class Chrome:
self.browser.navigate_to(self.address_bar.get_text())
def on_draw(self, drawing_area, context, width, height):
"""Callback for drawing the content area using Skia."""
"""Callback for drawing the content area using Skia."""
# Track frame time for FPS calculation
current_time = time.time()
self.frame_times.append(current_time)
@ -329,17 +330,17 @@ class Chrome:
self.frame_times = [t for t in self.frame_times if current_time - t < 1.0]
if len(self.frame_times) > 1:
self.fps = len(self.frame_times)
# Profiling timers
profile_start = time.perf_counter()
timings = {}
# Create Skia surface for this frame
t0 = time.perf_counter()
self.skia_surface = skia.Surface(width, height)
canvas = self.skia_surface.getCanvas()
timings['surface_create'] = time.perf_counter() - t0
# Store viewport height
self.viewport_height = height
@ -355,11 +356,11 @@ class Chrome:
t0 = time.perf_counter()
self._render_dom_content(canvas, document, width, height)
timings['render_dom'] = time.perf_counter() - t0
t0 = time.perf_counter()
self._draw_scrollbar(canvas, width, height)
timings['scrollbar'] = time.perf_counter() - t0
if self.debug_mode:
self._draw_fps_counter(canvas, width)
else:
@ -373,11 +374,11 @@ class Chrome:
t0 = time.perf_counter()
image = self.skia_surface.makeImageSnapshot()
timings['snapshot'] = time.perf_counter() - t0
t0 = time.perf_counter()
pixels = image.tobytes()
timings['tobytes'] = time.perf_counter() - t0
# Create Cairo ImageSurface from raw pixels
t0 = time.perf_counter()
cairo_surface = cairo.ImageSurface.create_for_data(
@ -388,39 +389,39 @@ class Chrome:
width * 4 # stride
)
timings['cairo_surface'] = time.perf_counter() - t0
# Blit Cairo surface to context
t0 = time.perf_counter()
context.set_source_surface(cairo_surface, 0, 0)
context.paint()
timings['cairo_blit'] = time.perf_counter() - t0
total_time = time.perf_counter() - profile_start
# Store profiling data for debug display
if self.debug_mode:
self._last_profile = timings
self._last_profile_total = total_time
def _render_dom_content(self, canvas, document, width: int, height: int):
"""Render the DOM content using the render pipeline."""
sub_timings = {}
# Sync debug mode with render pipeline
self.render_pipeline.debug_mode = self.debug_mode
# Use render pipeline for layout and rendering
t0 = time.perf_counter()
self.render_pipeline.render(canvas, document, width, height, self.scroll_y)
sub_timings['render'] = time.perf_counter() - t0
# Get text layout for selection
t0 = time.perf_counter()
self.text_layout = self.render_pipeline.get_text_layout()
self.document_height = self.render_pipeline.get_document_height()
sub_timings['get_layout'] = time.perf_counter() - t0
# Draw selection highlight (still in chrome as it's UI interaction)
t0 = time.perf_counter()
if self.selection_start and self.selection_end:
@ -429,61 +430,63 @@ class Chrome:
self._draw_text_selection(canvas)
canvas.restore()
sub_timings['selection'] = time.perf_counter() - t0
# Store sub-timings for display
if self.debug_mode:
self._render_sub_timings = sub_timings
self._visible_line_count = len([l for l in self.text_layout
if self.scroll_y - 50 <= l["y"] + l["font_size"] <= self.scroll_y + height + 50])
self._visible_line_count = len([
line for line in self.text_layout
if self.scroll_y - 50 <= line["y"] + line["font_size"] <= self.scroll_y + height + 50
])
def _draw_selection_highlight(self, canvas, width: int):
"""Draw selection highlight rectangle."""
if not self.selection_start or not self.selection_end:
return
x1, y1 = self.selection_start
x2, y2 = self.selection_end
# Normalize coordinates
left = min(x1, x2)
right = max(x1, x2)
top = min(y1, y2)
bottom = max(y1, y2)
paint = skia.Paint()
paint.setColor(skia.Color(100, 149, 237, 80)) # Cornflower blue, semi-transparent
paint.setStyle(skia.Paint.kFill_Style)
rect = skia.Rect.MakeLTRB(left, top, right, bottom)
canvas.drawRect(rect, paint)
def _draw_fps_counter(self, canvas, width: int):
"""Draw FPS counter and profiling info in top-right corner."""
font = get_font(11)
small_font = get_font(9)
# Calculate panel size based on profile data
panel_width = 200
num_profile_lines = len(self._last_profile) + 2 # +2 for FPS and total
num_sub_lines = len(self._render_sub_timings) + 1 if self._render_sub_timings else 0
panel_height = 18 + num_profile_lines * 12 + num_sub_lines * 11 + 10
# Position in top-right
panel_x = width - panel_width - 10
panel_y = 10
# Background
bg_paint = skia.Paint()
bg_paint.setColor(skia.Color(0, 0, 0, 200))
bg_paint.setStyle(skia.Paint.kFill_Style)
canvas.drawRect(skia.Rect.MakeLTRB(
panel_x, panel_y,
panel_x, panel_y,
panel_x + panel_width, panel_y + panel_height
), bg_paint)
text_paint = skia.Paint()
text_paint.setAntiAlias(True)
# FPS with color
if self.fps >= 50:
text_paint.setColor(skia.Color(100, 255, 100, 255))
@ -491,29 +494,29 @@ class Chrome:
text_paint.setColor(skia.Color(255, 255, 100, 255))
else:
text_paint.setColor(skia.Color(255, 100, 100, 255))
y = panel_y + 14
canvas.drawString(f"FPS: {self.fps:.0f}", panel_x + 5, y, font, text_paint)
# Total frame time
text_paint.setColor(skia.ColorWHITE)
total_ms = self._last_profile_total * 1000
y += 14
canvas.drawString(f"Frame: {total_ms:.1f}ms", panel_x + 5, y, font, text_paint)
# Profile breakdown
gray_paint = skia.Paint()
gray_paint.setAntiAlias(True)
gray_paint.setColor(skia.Color(180, 180, 180, 255))
if self._last_profile:
# Sort by time descending
sorted_items = sorted(
self._last_profile.items(),
key=lambda x: x[1],
self._last_profile.items(),
key=lambda x: x[1],
reverse=True
)
for name, duration in sorted_items:
y += 12
ms = duration * 1000
@ -526,13 +529,19 @@ class Chrome:
else:
gray_paint.setColor(skia.Color(180, 180, 180, 255))
canvas.drawString(f"{name}: {ms:.1f}ms ({pct:.0f}%)", panel_x + 8, y, small_font, gray_paint)
# Show render_dom sub-timings if available
if self._render_sub_timings:
y += 16
text_paint.setColor(skia.Color(150, 200, 255, 255))
canvas.drawString(f"render_dom breakdown ({self._visible_line_count} lines):", panel_x + 5, y, small_font, text_paint)
canvas.drawString(
f"render_dom breakdown ({self._visible_line_count} lines):",
panel_x + 5,
y,
small_font,
text_paint,
)
sub_sorted = sorted(self._render_sub_timings.items(), key=lambda x: x[1], reverse=True)
for name, duration in sub_sorted:
y += 11
@ -544,35 +553,35 @@ class Chrome:
"""Trigger redraw of the drawing area."""
if self.drawing_area:
self.drawing_area.queue_draw()
def _setup_keyboard_shortcuts(self):
"""Setup keyboard event handling for shortcuts."""
if not self.window:
return
# Create event controller for key presses
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_key_pressed)
self.window.add_controller(key_controller)
def _on_key_pressed(self, controller, keyval, keycode, state):
"""Handle keyboard shortcuts."""
# Check for Ctrl+Shift+D (DOM graph visualization)
ctrl_pressed = state & Gdk.ModifierType.CONTROL_MASK
shift_pressed = state & Gdk.ModifierType.SHIFT_MASK
key_name = Gdk.keyval_name(keyval)
# Ctrl+Shift+D: DOM graph visualization
if ctrl_pressed and shift_pressed and key_name in ('D', 'd'):
self._show_dom_graph()
return True
# Ctrl+Shift+O: Toggle debug mode (DOM outline visualization)
if ctrl_pressed and shift_pressed and key_name in ('O', 'o'):
self._toggle_debug_mode()
return True
# Page scrolling with arrow keys, Page Up/Down, Home/End
scroll_amount = 50
if key_name == 'Down':
@ -602,86 +611,86 @@ class Chrome:
else:
self._scroll_by(400)
return True
return False # Event not handled
def _toggle_debug_mode(self):
"""Toggle debug mode for DOM visualization."""
self.debug_mode = not self.debug_mode
mode_str = "ON" if self.debug_mode else "OFF"
self.logger.info(f"Debug mode: {mode_str}")
self.paint()
def _scroll_by(self, delta: int):
"""Scroll the page by the given amount, clamped to document bounds."""
max_scroll = max(0, self.document_height - self.viewport_height)
self.scroll_y = max(0, min(max_scroll, self.scroll_y + delta))
self._show_scrollbar()
self.paint()
def _show_scrollbar(self):
"""Show scrollbar and schedule fade out."""
from gi.repository import GLib
self.scrollbar_opacity = 1.0
# Cancel any existing fade timeout
if self.scrollbar_fade_timeout:
GLib.source_remove(self.scrollbar_fade_timeout)
# Schedule fade out after 1 second
self.scrollbar_fade_timeout = GLib.timeout_add(1000, self._fade_scrollbar)
def _fade_scrollbar(self):
"""Gradually fade out the scrollbar."""
from gi.repository import GLib
self.scrollbar_opacity -= 0.1
if self.scrollbar_opacity <= 0:
self.scrollbar_opacity = 0
self.scrollbar_fade_timeout = None
self.paint()
return False # Stop the timeout
self.paint()
# Continue fading
self.scrollbar_fade_timeout = GLib.timeout_add(50, self._fade_scrollbar)
return False # This instance is done
def _draw_scrollbar(self, canvas, width: int, height: int):
"""Draw the scrollbar overlay."""
if self.scrollbar_opacity <= 0 or self.document_height <= height:
return
# Calculate scrollbar dimensions
scrollbar_width = 8
scrollbar_margin = 4
scrollbar_x = width - scrollbar_width - scrollbar_margin
# Track height (full viewport)
track_height = height - 2 * scrollbar_margin
# Thumb size proportional to viewport/document ratio
thumb_ratio = height / self.document_height
thumb_height = max(30, track_height * thumb_ratio)
# Thumb position based on scroll position
max_scroll = max(1, self.document_height - height)
scroll_ratio = self.scroll_y / max_scroll
thumb_y = scrollbar_margin + scroll_ratio * (track_height - thumb_height)
# Draw track (subtle)
alpha = int(30 * self.scrollbar_opacity)
track_paint = skia.Paint()
track_paint.setColor(skia.Color(0, 0, 0, alpha))
track_paint.setStyle(skia.Paint.kFill_Style)
track_rect = skia.RRect.MakeRectXY(
skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin,
skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin,
scrollbar_x + scrollbar_width, height - scrollbar_margin),
scrollbar_width / 2, scrollbar_width / 2
)
canvas.drawRRect(track_rect, track_paint)
# Draw thumb
alpha = int(150 * self.scrollbar_opacity)
thumb_paint = skia.Paint()
@ -693,20 +702,20 @@ class Chrome:
scrollbar_width / 2, scrollbar_width / 2
)
canvas.drawRRect(thumb_rect, thumb_paint)
def _on_scroll(self, controller, dx, dy):
"""Handle mouse wheel scroll."""
scroll_amount = int(dy * 50) # Scale scroll amount
self._scroll_by(scroll_amount)
return True
def _on_mouse_pressed(self, gesture, n_press, x, y):
"""Handle mouse button press for text selection."""
self.selection_start = (x, y + self.scroll_y)
self.selection_end = None
self.is_selecting = True
self.drawing_area.grab_focus()
def _on_mouse_released(self, gesture, n_press, x, y):
"""Handle mouse button release for text selection."""
if self.is_selecting:
@ -719,70 +728,78 @@ class Chrome:
# Copy to clipboard
self._copy_to_clipboard(selected_text)
self.paint()
def _on_mouse_motion(self, controller, x, y):
"""Handle mouse motion for drag selection."""
if self.is_selecting:
self.selection_end = (x, y + self.scroll_y)
self.paint()
def _draw_text_selection(self, canvas):
"""Draw selection highlight for selected text at character level."""
if not self.selection_start or not self.selection_end:
return
# Normalize selection: start should be before end in reading order
if (self.selection_start[1] > self.selection_end[1] or
(self.selection_start[1] == self.selection_end[1] and
(self.selection_start[1] == self.selection_end[1] and
self.selection_start[0] > self.selection_end[0])):
sel_start = self.selection_end
sel_end = self.selection_start
else:
sel_start = self.selection_start
sel_end = self.selection_end
paint = skia.Paint()
paint.setColor(skia.Color(100, 149, 237, 100)) # Cornflower blue
paint.setStyle(skia.Paint.kFill_Style)
for line_info in self.text_layout:
line_top = line_info["y"]
line_bottom = line_info["y"] + line_info["height"]
line_left = line_info["x"]
char_positions = line_info.get("char_positions", [])
text = line_info["text"]
# text not needed for highlight geometry
# Skip lines completely outside selection
if line_bottom < sel_start[1] or line_top > sel_end[1]:
continue
# Determine selection bounds for this line
hl_left = line_left
hl_right = line_left + line_info["width"]
# If this line contains the start of selection
if line_top <= sel_start[1] < line_bottom:
# Find character index at sel_start x
start_char_idx = self._x_to_char_index(sel_start[0], line_left, char_positions)
hl_left = line_left + char_positions[start_char_idx] if start_char_idx < len(char_positions) else line_left
hl_left = (
line_left + char_positions[start_char_idx]
if start_char_idx < len(char_positions)
else line_left
)
# If this line contains the end of selection
if line_top <= sel_end[1] < line_bottom:
# Find character index at sel_end x
end_char_idx = self._x_to_char_index(sel_end[0], line_left, char_positions)
hl_right = line_left + char_positions[end_char_idx] if end_char_idx < len(char_positions) else hl_right
hl_right = (
line_left + char_positions[end_char_idx]
if end_char_idx < len(char_positions)
else hl_right
)
# Draw highlight
if hl_right > hl_left:
rect = skia.Rect.MakeLTRB(hl_left, line_top, hl_right, line_bottom)
canvas.drawRect(rect, paint)
def _x_to_char_index(self, x: float, line_x: float, char_positions: list) -> int:
"""Convert x coordinate to character index within a line."""
rel_x = x - line_x
if rel_x <= 0:
return 0
# Binary search for the character position
for i, pos in enumerate(char_positions):
if pos >= rel_x:
@ -790,82 +807,82 @@ class Chrome:
if i > 0 and (pos - rel_x) > (rel_x - char_positions[i-1]):
return i - 1
return i
return len(char_positions) - 1
def _get_selected_text(self) -> str:
"""Extract text from the current selection at character level."""
if not self.selection_start or not self.selection_end or not self.text_layout:
return ""
# Normalize selection: start should be before end in reading order
if (self.selection_start[1] > self.selection_end[1] or
(self.selection_start[1] == self.selection_end[1] and
(self.selection_start[1] == self.selection_end[1] and
self.selection_start[0] > self.selection_end[0])):
sel_start = self.selection_end
sel_end = self.selection_start
else:
sel_start = self.selection_start
sel_end = self.selection_end
selected_parts = []
for line_info in self.text_layout:
line_top = line_info["y"]
line_bottom = line_info["y"] + line_info["height"]
line_left = line_info["x"]
char_positions = line_info.get("char_positions", [])
text = line_info["text"]
# Skip lines completely outside selection
if line_bottom < sel_start[1] or line_top > sel_end[1]:
continue
start_idx = 0
end_idx = len(text)
# If this line contains the start of selection
if line_top <= sel_start[1] < line_bottom:
start_idx = self._x_to_char_index(sel_start[0], line_left, char_positions)
# If this line contains the end of selection
if line_top <= sel_end[1] < line_bottom:
end_idx = self._x_to_char_index(sel_end[0], line_left, char_positions)
# Extract the selected portion
if end_idx > start_idx:
selected_parts.append(text[start_idx:end_idx])
return " ".join(selected_parts)
def _copy_to_clipboard(self, text: str):
"""Copy text to system clipboard."""
clipboard = Gdk.Display.get_default().get_clipboard()
clipboard.set(text)
def _show_dom_graph(self):
"""Generate and display DOM graph for current tab."""
from ..debug.dom_graph import render_dom_graph_to_svg, save_dom_graph, print_dom_tree
if not self.browser.active_tab:
self.logger.warning("No active tab to visualize")
return
frame = self.browser.active_tab.main_frame
if not frame or not frame.document:
self.logger.warning("No document to visualize")
return
# Generate output path
output_dir = Path.home() / ".cache" / "bowser"
output_dir.mkdir(parents=True, exist_ok=True)
# Try SVG first, fallback to DOT
svg_path = output_dir / "dom_graph.svg"
dot_path = output_dir / "dom_graph.dot"
self.logger.info("Generating DOM graph...")
# Print tree to console for debugging
tree_text = print_dom_tree(frame.document, max_depth=15)
print("\n" + "="*60)
@ -873,7 +890,7 @@ class Chrome:
print("="*60)
print(tree_text)
print("="*60 + "\n")
# Try to render as SVG
if render_dom_graph_to_svg(frame.document, str(svg_path)):
# Open in new browser tab
@ -884,7 +901,7 @@ class Chrome:
if save_dom_graph(frame.document, str(dot_path)):
self.logger.info(f"Opening DOM graph (DOT format) in new tab: {dot_path}")
self.browser.new_tab(f"about:dom-graph?path={dot_path}")
def _show_info_dialog(self, title: str, message: str):
"""Show an information dialog."""
dialog = Gtk.MessageDialog(

View file

@ -1,6 +1,6 @@
"""Tab and frame orchestration stubs."""
from typing import Optional
from typing import Optional, TYPE_CHECKING
import logging
from ..network.url import URL
@ -8,6 +8,9 @@ from ..network import http
from ..parser.html import parse_html, Element
from ..templates import render_startpage, render_error_page
if TYPE_CHECKING:
from .browser import Browser
class Frame:
def __init__(self, tab: "Tab", parent_frame=None, frame_element=None):
@ -19,7 +22,7 @@ class Frame:
def load(self, url: URL, payload: Optional[bytes] = None):
"""Fetch and parse the URL content."""
logger = logging.getLogger("bowser.frame")
# Handle special about: URLs
url_str = str(url)
if url_str.startswith("about:startpage"):
@ -27,7 +30,7 @@ class Frame:
self.document = parse_html(html)
self.tab.current_url = url
return
if url_str.startswith("about:dom-graph"):
# Extract path parameter
from urllib.parse import urlparse, parse_qs
@ -35,19 +38,19 @@ class Frame:
parsed = urlparse(url_str)
params = parse_qs(parsed.query)
graph_path = params.get('path', [''])[0]
html = render_dom_graph_page(graph_path)
self.document = parse_html(html)
self.tab.current_url = url
return
try:
status, content_type, body = http.request(url, payload)
if status == 200:
# Decode response
text = body.decode('utf-8', errors='replace')
# Parse HTML
self.document = parse_html(text)
self.tab.current_url = url
@ -55,7 +58,7 @@ class Frame:
# Error handling - show error page
html = render_error_page(status, str(url))
self.document = parse_html(html)
except Exception as e:
# Network error - show error page
html = render_error_page(0, str(url), str(e))

View file

@ -8,33 +8,33 @@ from ..parser.html import Element, Text
def generate_dot_graph(document: Optional[Element]) -> str:
"""
Generate a Graphviz DOT representation of the DOM tree.
Args:
document: Root element of the DOM tree
Returns:
DOT format string representing the DOM tree
"""
if not document:
return 'digraph DOM {\n label="Empty Document";\n}\n'
lines = []
lines.append('digraph DOM {')
lines.append(' rankdir=TB;')
lines.append(' node [shape=box, style=filled];')
lines.append('')
node_counter = [0] # mutable counter for unique IDs
def escape_label(text: str) -> str:
"""Escape special characters for DOT labels."""
return text.replace('"', '\\"').replace('\n', '\\n')
def add_node(node, parent_id: Optional[str] = None) -> str:
"""Recursively add nodes to the graph."""
node_id = f'node_{node_counter[0]}'
node_counter[0] += 1
if isinstance(node, Text):
# Text nodes
text_preview = node.text[:50] + ('...' if len(node.text) > 50 else '')
@ -48,9 +48,9 @@ def generate_dot_graph(document: Optional[Element]) -> str:
if len(node.attributes) > 3:
attrs_list.append('...')
attrs_str = '\\n' + ' '.join(attrs_list)
label = f'<{escape_label(node.tag)}>{escape_label(attrs_str)}'
# Color code by tag type
if node.tag in ('html', 'body'):
color = 'lightgreen'
@ -64,45 +64,45 @@ def generate_dot_graph(document: Optional[Element]) -> str:
color = 'lightpink'
else:
color = 'white'
lines.append(f' {node_id} [label="{label}", fillcolor={color}];')
# Add edges to children
if hasattr(node, 'children'):
for child in node.children:
child_id = add_node(child, node_id)
lines.append(f' {node_id} -> {child_id};')
return node_id
add_node(document)
lines.append('}')
return '\n'.join(lines)
def save_dom_graph(document: Optional[Element], output_path: str) -> bool:
"""
Save DOM tree as a DOT file.
Args:
document: Root element of the DOM tree
output_path: Path where to save the .dot file
Returns:
True if successful, False otherwise
"""
logger = logging.getLogger("bowser.debug")
try:
dot_content = generate_dot_graph(document)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(dot_content)
logger.info(f"DOM graph saved to {output_path}")
return True
except Exception as e:
logger.error(f"Failed to save DOM graph: {e}")
return False
@ -111,21 +111,21 @@ def save_dom_graph(document: Optional[Element], output_path: str) -> bool:
def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bool:
"""
Render DOM tree as an SVG image using Graphviz (if available).
Args:
document: Root element of the DOM tree
output_path: Path where to save the .svg file
Returns:
True if successful, False otherwise
"""
logger = logging.getLogger("bowser.debug")
try:
import subprocess
dot_content = generate_dot_graph(document)
# Try to render with graphviz
result = subprocess.run(
['dot', '-Tsvg', '-o', output_path],
@ -133,14 +133,14 @@ def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bo
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"DOM graph rendered to {output_path}")
return True
else:
logger.warning(f"Graphviz rendering failed: {result.stderr.decode()}")
return False
except FileNotFoundError:
logger.warning("Graphviz 'dot' command not found. Install graphviz for SVG output.")
return False
@ -152,21 +152,21 @@ def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bo
def print_dom_tree(node, indent: int = 0, max_depth: int = 10) -> str:
"""
Generate a text representation of the DOM tree.
Args:
node: DOM node to print
indent: Current indentation level
max_depth: Maximum depth to traverse
Returns:
String representation of the tree
"""
if indent > max_depth:
return " " * indent + "...\n"
lines = []
spacer = " " * indent
if isinstance(node, Text):
text_preview = node.text.strip()[:60]
if text_preview:
@ -176,11 +176,11 @@ def print_dom_tree(node, indent: int = 0, max_depth: int = 10) -> str:
if node.attributes:
attrs_preview = {k: v for k, v in list(node.attributes.items())[:3]}
attrs_str = f" {attrs_preview}"
lines.append(f"{spacer}<{node.tag}>{attrs_str}\n")
if hasattr(node, 'children'):
for child in node.children:
lines.append(print_dom_tree(child, indent + 1, max_depth))
return "".join(lines)

View file

@ -6,7 +6,7 @@ from ..parser.html import Element, Text
class LineLayout:
"""Layout for a single line of text."""
def __init__(self, parent=None):
self.parent = parent
self.words = [] # List of (text, x, font_size)
@ -15,7 +15,7 @@ class LineLayout:
self.width = 0
self.height = 0
self.font_size = 14
def add_word(self, word: str, x: float, font_size: int):
"""Add a word to this line."""
self.words.append((word, x, font_size))
@ -27,7 +27,7 @@ class LineLayout:
class BlockLayout:
"""Layout for a block-level element."""
def __init__(self, node, parent=None, previous=None, frame=None):
self.node = node
self.parent = parent
@ -44,30 +44,30 @@ class BlockLayout:
self.font_size = 14
self.block_type = "block"
self.tag = ""
def layout(self, x: float, y: float, max_width: float):
"""Layout this block and return the height used."""
self.x = x
self.y = y
self.width = max_width
current_y = y + self.margin_top
# Layout children
for child in self.children:
child.layout(x, current_y, max_width)
current_y += child.height + child.margin_bottom
# Layout lines
for line in self.lines:
line.y = current_y
current_y += line.height
self.height = current_y - y + self.margin_bottom
return self.height
def build_block_layout(node, parent=None, font_size: int = 14,
def build_block_layout(node, parent=None, font_size: int = 14,
margin_top: int = 6, margin_bottom: int = 10,
block_type: str = "block", bullet: bool = False) -> BlockLayout:
"""Build a BlockLayout from a DOM node."""
@ -77,17 +77,17 @@ def build_block_layout(node, parent=None, font_size: int = 14,
block.margin_bottom = margin_bottom
block.block_type = block_type
block.tag = node.tag if isinstance(node, Element) else ""
# Collect text content
text = _extract_text(node)
if bullet and text:
text = f"{text}"
if text:
block._raw_text = text
else:
block._raw_text = ""
return block

View file

@ -2,12 +2,11 @@
from ..parser.html import Element, Text
from ..render.fonts import get_font, linespace
from .block import BlockLayout, LineLayout
class LayoutLine:
"""A laid-out line ready for rendering."""
def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None):
self.text = text
self.x = x
@ -16,7 +15,7 @@ class LayoutLine:
self.height = linespace(font_size)
self.width = 0
self.char_positions = char_positions or []
# Calculate width
if text:
font = get_font(font_size)
@ -25,7 +24,7 @@ class LayoutLine:
class LayoutBlock:
"""A laid-out block with its lines."""
def __init__(self, tag: str, block_type: str = "block"):
self.tag = tag
self.block_type = block_type
@ -38,7 +37,7 @@ class LayoutBlock:
class DocumentLayout:
"""Layout engine for a document."""
def __init__(self, node, frame=None):
self.node = node
self.frame = frame
@ -46,29 +45,29 @@ class DocumentLayout:
self.lines = [] # Flat list of all LayoutLine for rendering
self.width = 0
self.height = 0
def layout(self, width: int, x_margin: int = 20, y_start: int = 30) -> list:
"""
Layout the document and return a list of LayoutLine objects.
Returns:
List of LayoutLine objects ready for rendering
"""
self.width = width
max_width = max(10, width - 2 * x_margin)
y = y_start
self.blocks = []
self.lines = []
# Find body
body = self._find_body(self.node)
if not body:
return self.lines
# Collect and layout blocks
raw_blocks = self._collect_blocks(body)
for block_info in raw_blocks:
font_size = block_info.get("font_size", 14)
text = block_info.get("text", "")
@ -76,26 +75,26 @@ class DocumentLayout:
margin_bottom = block_info.get("margin_bottom", 10)
block_type = block_info.get("block_type", "block")
tag = block_info.get("tag", "")
if not text:
y += font_size * 0.6
continue
# Optional bullet prefix
if block_info.get("bullet"):
text = f"{text}"
layout_block = LayoutBlock(tag, block_type)
layout_block.x = x_margin
layout_block.y = y + margin_top
# Word wrap
font = get_font(font_size)
words = text.split()
wrapped_lines = []
current_line = []
current_width = 0
for word in words:
word_width = font.measureText(word + " ")
if current_width + word_width > max_width and current_line:
@ -107,18 +106,18 @@ class DocumentLayout:
current_width += word_width
if current_line:
wrapped_lines.append(" ".join(current_line))
# Create LayoutLines
line_height = linespace(font_size)
y += margin_top
block_start_y = y
for line_text in wrapped_lines:
# Calculate character positions
char_positions = [0.0]
for i in range(1, len(line_text) + 1):
char_positions.append(font.measureText(line_text[:i]))
layout_line = LayoutLine(
text=line_text,
x=x_margin,
@ -126,20 +125,20 @@ class DocumentLayout:
font_size=font_size,
char_positions=char_positions
)
layout_block.lines.append(layout_line)
self.lines.append(layout_line)
y += line_height
layout_block.height = y - block_start_y
layout_block.width = max_width
self.blocks.append(layout_block)
y += margin_bottom
self.height = y + 50 # Padding at bottom
return self.lines
def _find_body(self, node):
"""Find the body element in the document."""
if isinstance(node, Element) and node.tag == "body":
@ -152,27 +151,27 @@ class DocumentLayout:
if found:
return found
return None
def _collect_blocks(self, node) -> list:
"""Collect renderable blocks from the DOM."""
blocks = []
for child in getattr(node, "children", []):
if isinstance(child, Text):
txt = child.text.strip()
if txt:
blocks.append({"text": txt, "font_size": 14, "block_type": "text"})
continue
if isinstance(child, Element):
tag = child.tag.lower()
content = self._text_of(child)
if not content:
continue
if tag == "h1":
blocks.append({
"text": content, "font_size": 24,
"text": content, "font_size": 24,
"margin_top": 12, "margin_bottom": 12,
"block_type": "block", "tag": "h1"
})
@ -215,9 +214,9 @@ class DocumentLayout:
"text": content, "font_size": 14,
"block_type": "block", "tag": tag
})
return blocks
def _text_of(self, node) -> str:
"""Extract text content from a node."""
if isinstance(node, Text):

View file

@ -1,11 +1,11 @@
"""Inline and text layout."""
from ..render.fonts import get_font, measure_text, linespace
from ..render.fonts import get_font, linespace
class TextLayout:
"""Layout for a single word/text run."""
def __init__(self, node, word: str, parent=None, previous=None):
self.node = node
self.word = word
@ -16,7 +16,7 @@ class TextLayout:
self.width = 0
self.height = 0
self.font_size = 14
def layout(self, font_size: int = 14):
"""Calculate layout for this text."""
self.font_size = font_size
@ -28,7 +28,7 @@ class TextLayout:
class InlineLayout:
"""Layout for inline content (text runs within a line)."""
def __init__(self, node, parent=None):
self.node = node
self.parent = parent
@ -37,14 +37,14 @@ class InlineLayout:
self.y = 0
self.width = 0
self.height = 0
def add_word(self, word: str, font_size: int = 14):
"""Add a word to this inline layout."""
text_layout = TextLayout(self.node, word, parent=self)
text_layout.layout(font_size)
self.children.append(text_layout)
return text_layout
def layout(self, x: float, y: float, max_width: float, font_size: int = 14):
"""Layout all words, wrapping as needed. Returns list of lines."""
lines = []
@ -52,7 +52,7 @@ class InlineLayout:
current_x = x
line_y = y
line_height = linespace(font_size)
for child in self.children:
if current_x + child.width > x + max_width and current_line:
# Wrap to next line
@ -60,14 +60,14 @@ class InlineLayout:
current_line = []
current_x = x
line_y += line_height
child.x = current_x
child.y = line_y
current_line.append(child)
current_x += child.width
if current_line:
lines.append((current_line, line_y, line_height))
self.height = line_y + line_height - y if lines else 0
return lines

View file

@ -7,60 +7,65 @@ import logging
from .url import URL
def request(url: URL, payload: Optional[bytes] = None, method: str = "GET", max_redirects: int = 10) -> Tuple[int, str, bytes]:
def request(
url: URL,
payload: Optional[bytes] = None,
method: str = "GET",
max_redirects: int = 10,
) -> Tuple[int, str, bytes]:
"""
Fetch a URL and follow redirects, returning (status_code, content_type, body).
Args:
url: URL to fetch
payload: Optional request body
method: HTTP method (GET, POST, etc.)
max_redirects: Maximum number of redirects to follow (default 10)
Returns:
Tuple of (status_code, content_type, response_body)
"""
logger = logging.getLogger("bowser.network")
current_url = url
redirect_count = 0
while redirect_count < max_redirects:
parsed = current_url._parsed
conn_class = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
try:
conn = conn_class(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
headers = {
"User-Agent": "Bowser/0.0.1",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
logger.info(f"HTTP {method} {parsed.scheme}://{parsed.hostname}{path}")
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
status = resp.status
content_type = resp.getheader("Content-Type", "text/html")
body = resp.read()
logger.info(f"HTTP response {status} {resp.reason} ({len(body)} bytes)")
# Handle redirects (3xx status codes)
if 300 <= status < 400 and status != 304:
location = resp.getheader("Location")
conn.close()
if not location:
logger.warning(f"Redirect response {status} without Location header")
return status, content_type, body
logger.info(f"Following redirect to {location}")
redirect_count += 1
# Convert relative URLs to absolute
if location.startswith("http://") or location.startswith("https://"):
current_url = URL(location)
@ -70,21 +75,21 @@ def request(url: URL, payload: Optional[bytes] = None, method: str = "GET", max_
if parsed.port:
base_url += f":{parsed.port}"
current_url = URL(base_url + location)
# For 303 (See Other), change method to GET
if status == 303:
method = "GET"
payload = None
continue
conn.close()
return status, content_type, body
except Exception as e:
logger.error(f"HTTP request failed: {e}")
raise
# Max redirects exceeded
logger.error(f"Maximum redirects ({max_redirects}) exceeded")
raise Exception(f"Too many redirects (max: {max_redirects})")

View file

@ -5,22 +5,22 @@ import skia
class FontCache:
"""Cache for Skia fonts and typefaces."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._font_cache = {}
cls._instance._default_typeface = None
return cls._instance
def get_typeface(self):
"""Get the default typeface, creating it if needed."""
if self._default_typeface is None:
self._default_typeface = skia.Typeface.MakeDefault()
return self._default_typeface
def get_font(self, size: int, weight: str = "normal", style: str = "normal") -> skia.Font:
"""Get a cached Skia font for the given parameters."""
key = (size, weight, style)
@ -28,11 +28,11 @@ class FontCache:
typeface = self.get_typeface()
self._font_cache[key] = skia.Font(typeface, size)
return self._font_cache[key]
def measure_text(self, text: str, font: skia.Font) -> float:
"""Measure the width of text using the given font."""
return font.measureText(text)
def get_line_height(self, font_size: int) -> float:
"""Get the line height for a given font size."""
return font_size * 1.4

View file

@ -6,10 +6,10 @@ from .fonts import get_font
class PaintCommand:
"""Base class for paint commands."""
def __init__(self, rect):
self.rect = rect # (x1, y1, x2, y2) bounding box
def execute(self, canvas: skia.Canvas):
"""Execute this paint command on the canvas."""
raise NotImplementedError
@ -17,7 +17,7 @@ class PaintCommand:
class DrawText(PaintCommand):
"""Command to draw text."""
def __init__(self, x: float, y: float, text: str, font_size: int, color=None):
self.x = x
self.y = y
@ -27,7 +27,7 @@ class DrawText(PaintCommand):
self._font = get_font(font_size)
width = self._font.measureText(text)
super().__init__((x, y - font_size, x + width, y))
def execute(self, canvas: skia.Canvas, paint: skia.Paint = None):
"""Draw the text on the canvas."""
if paint is None:
@ -39,12 +39,12 @@ class DrawText(PaintCommand):
class DrawRect(PaintCommand):
"""Command to draw a rectangle."""
def __init__(self, x1: float, y1: float, x2: float, y2: float, color, fill: bool = True):
super().__init__((x1, y1, x2, y2))
self.color = color
self.fill = fill
def execute(self, canvas: skia.Canvas, paint: skia.Paint = None):
"""Draw the rectangle on the canvas."""
if paint is None:
@ -57,21 +57,21 @@ class DrawRect(PaintCommand):
class DisplayList:
"""A list of paint commands to execute."""
def __init__(self):
self.commands = []
def append(self, command: PaintCommand):
"""Add a paint command."""
self.commands.append(command)
def execute(self, canvas: skia.Canvas, paint: skia.Paint = None):
"""Execute all commands on the canvas."""
for cmd in self.commands:
cmd.execute(canvas, paint)
def __len__(self):
return len(self.commands)
def __iter__(self):
return iter(self.commands)

View file

@ -5,92 +5,92 @@ from typing import Optional
from ..parser.html import Element
from ..layout.document import DocumentLayout
from .fonts import get_font
from .paint import DisplayList, DrawText, DrawRect
from .paint import DisplayList
class RenderPipeline:
"""Coordinates layout calculation and rendering to a Skia canvas."""
def __init__(self):
# Layout cache
self._layout: Optional[DocumentLayout] = None
self._layout_width = 0
self._layout_doc_id = None
# Paint cache
self._text_paint: Optional[skia.Paint] = None
self._display_list: Optional[DisplayList] = None
# Debug mode
self.debug_mode = False
def layout(self, document: Element, width: int) -> DocumentLayout:
"""
Calculate layout for the document.
Returns the DocumentLayout with all positioned elements.
"""
doc_id = id(document)
# Check cache
if (self._layout_doc_id == doc_id and
self._layout_width == width and
if (self._layout_doc_id == doc_id and
self._layout_width == width and
self._layout is not None):
return self._layout
# Build new layout
self._layout = DocumentLayout(document)
self._layout.layout(width)
self._layout_doc_id = doc_id
self._layout_width = width
return self._layout
def render(self, canvas: skia.Canvas, document: Element,
def render(self, canvas: skia.Canvas, document: Element,
width: int, height: int, scroll_y: float = 0):
"""
Render the document to the canvas.
Args:
canvas: Skia canvas to draw on
document: DOM document root
width: Viewport width
height: Viewport height
height: Viewport height
scroll_y: Vertical scroll offset
"""
# Get or update layout
layout = self.layout(document, width)
if not layout.lines:
return
# Apply scroll transform
canvas.save()
canvas.translate(0, -scroll_y)
# Get paint
if self._text_paint is None:
self._text_paint = skia.Paint()
self._text_paint.setAntiAlias(True)
self._text_paint.setColor(skia.ColorBLACK)
# Render visible lines only
visible_top = scroll_y - 50
visible_bottom = scroll_y + height + 50
for line in layout.lines:
baseline_y = line.y + line.font_size
if baseline_y < visible_top or line.y > visible_bottom:
continue
font = get_font(line.font_size)
canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint)
# Draw debug overlays if enabled
if self.debug_mode:
self._render_debug_overlays(canvas, layout)
canvas.restore()
def _render_debug_overlays(self, canvas: skia.Canvas, layout: DocumentLayout):
"""Render debug bounding boxes for layout blocks."""
# Color scheme for different block types
@ -100,35 +100,35 @@ class RenderPipeline:
"list-item": (0, 255, 0, 60), # Green
"text": (255, 255, 0, 60), # Yellow
}
border_colors = {
"block": (255, 0, 0, 180),
"inline": (0, 0, 255, 180),
"list-item": (0, 255, 0, 180),
"text": (255, 255, 0, 180),
}
for block in layout.blocks:
block_type = block.block_type
# Calculate block bounds from lines
if not block.lines:
continue
x = block.x - 5
y = block.y - block.lines[0].font_size if block.lines else block.y
w = block.width + 10
h = block.height + 5
# Fill
fill_paint = skia.Paint()
c = colors.get(block_type, colors["block"])
fill_paint.setColor(skia.Color(*c))
fill_paint.setStyle(skia.Paint.kFill_Style)
rect = skia.Rect.MakeLTRB(x, y, x + w, y + h)
canvas.drawRect(rect, fill_paint)
# Border
border_paint = skia.Paint()
bc = border_colors.get(block_type, border_colors["block"])
@ -136,7 +136,7 @@ class RenderPipeline:
border_paint.setStyle(skia.Paint.kStroke_Style)
border_paint.setStrokeWidth(1)
canvas.drawRect(rect, border_paint)
def get_text_layout(self) -> list:
"""
Get the text layout for text selection.
@ -144,7 +144,7 @@ class RenderPipeline:
"""
if self._layout is None:
return []
result = []
for line in self._layout.lines:
result.append({
@ -157,13 +157,13 @@ class RenderPipeline:
"char_positions": line.char_positions
})
return result
def get_document_height(self) -> float:
"""Get the total document height for scrolling."""
if self._layout is None:
return 0
return self._layout.height
def invalidate(self):
"""Invalidate the layout cache, forcing recalculation."""
self._layout = None

View file

@ -1,6 +1,5 @@
"""Template rendering utilities."""
import os
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
import logging
@ -12,11 +11,11 @@ def get_template_env():
# The templates.py file is at src/templates.py, so parent.parent gets to project root
current_file = Path(__file__)
pages_dir = current_file.parent.parent / "assets" / "pages"
# If pages_dir doesn't exist, try alternative path (for tests)
if not pages_dir.exists():
pages_dir = Path("/home/bw/Workspace/bowser/assets/pages")
env = Environment(
loader=FileSystemLoader(str(pages_dir)),
autoescape=select_autoescape(['html', 'xml'])
@ -27,24 +26,24 @@ def get_template_env():
def render_template(template_name: str, **context) -> str:
"""
Render a template with the given context.
Args:
template_name: Name of the template file (e.g., 'startpage.html')
**context: Variables to pass to the template
Returns:
Rendered HTML string
"""
logger = logging.getLogger("bowser.templates")
try:
env = get_template_env()
template = env.get_template(template_name)
# Set default version if not provided
if 'version' not in context:
context['version'] = '0.0.1'
logger.debug(f"Rendering template: {template_name}")
return template.render(**context)
except Exception as e:
@ -55,32 +54,26 @@ def render_template(template_name: str, **context) -> str:
def render_error_page(status_code: int, url: str = "", error_message: str = "") -> str:
"""
Render an error page for the given status code.
Args:
status_code: HTTP status code (404, 500, etc.)
url: URL that caused the error
error_message: Optional error details
Returns:
Rendered error HTML
"""
logger = logging.getLogger("bowser.templates")
# Map common status codes to templates
template_map = {
404: "error_404.html",
500: "error_500.html",
# Network errors
"network": "error_network.html",
}
# Determine template per status
if status_code == 404:
template = "error_404.html"
elif status_code >= 500:
template = "error_500.html"
else:
template = "error_network.html"
try:
env = get_template_env()
tmpl = env.get_template(template)
@ -104,40 +97,40 @@ def render_startpage() -> str:
def render_dom_graph_page(graph_path: str) -> str:
"""
Render the DOM graph visualization page.
Args:
graph_path: Path to the SVG or DOT file
Returns:
Rendered HTML with embedded graph
"""
from pathlib import Path
logger = logging.getLogger("bowser.templates")
graph_path_obj = Path(graph_path)
if not graph_path_obj.exists():
logger.error(f"Graph file not found: {graph_path}")
return render_template("dom_graph.html",
return render_template("dom_graph.html",
error="Graph file not found",
graph_content="",
is_svg=False)
try:
# Check file type
is_svg = graph_path_obj.suffix == '.svg'
# Read the file
with open(graph_path, 'r', encoding='utf-8') as f:
graph_content = f.read()
logger.info(f"Rendering DOM graph from {graph_path}")
return render_template("dom_graph.html",
graph_content=graph_content,
is_svg=is_svg,
graph_path=str(graph_path),
error=None)
except Exception as e:
logger.error(f"Failed to read graph file {graph_path}: {e}")
return render_template("dom_graph.html",

View file

@ -1,6 +1,5 @@
"""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
@ -15,101 +14,101 @@ class TestTab:
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
@ -120,91 +119,91 @@ class TestBrowser:
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.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")
_ = 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

View file

@ -1,6 +1,5 @@
"""Tests for cookie management."""
import pytest
from src.network.cookies import CookieJar
@ -8,34 +7,34 @@ 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

View file

@ -1,7 +1,6 @@
"""Tests for DOM graph visualization."""
import pytest
from src.parser.html import parse_html, Element, Text
from src.parser.html import parse_html
from src.debug.dom_graph import generate_dot_graph, print_dom_tree
@ -9,36 +8,36 @@ class TestDOMGraph:
def test_generate_dot_graph_empty(self):
"""Test generating graph for None document."""
dot = generate_dot_graph(None)
assert "digraph DOM" in dot
assert "Empty Document" in dot
def test_generate_dot_graph_simple(self):
"""Test generating graph for simple HTML."""
html = "<html><body><p>Hello World</p></body></html>"
doc = parse_html(html)
dot = generate_dot_graph(doc)
assert "digraph DOM" in dot
assert "node_" in dot # Should have node IDs
assert "<html>" in dot
assert "<body>" in dot
assert "<p>" in dot
assert "Hello World" in dot
def test_generate_dot_graph_with_attributes(self):
"""Test graph generation with element attributes."""
html = '<html><body><div class="test" id="main">Content</div></body></html>'
doc = parse_html(html)
dot = generate_dot_graph(doc)
assert "digraph DOM" in dot
assert "<div>" in dot
# Attributes should be included (at least some of them)
assert "class" in dot or "id" in dot
def test_generate_dot_graph_nested(self):
"""Test graph generation with nested elements."""
html = """
@ -52,75 +51,75 @@ class TestDOMGraph:
</html>
"""
doc = parse_html(html)
dot = generate_dot_graph(doc)
assert "digraph DOM" in dot
assert "->" in dot # Should have edges
assert "First" in dot
assert "Second" in dot
def test_generate_dot_graph_colors(self):
"""Test that different element types get different colors."""
html = "<html><body><h1>Title</h1><p>Text</p><ul><li>Item</li></ul></body></html>"
doc = parse_html(html)
dot = generate_dot_graph(doc)
# Check for color attributes
assert "fillcolor=" in dot
assert "lightgreen" in dot or "lightyellow" in dot or "lightgray" in dot
def test_print_dom_tree_simple(self):
"""Test text tree representation."""
html = "<html><body><p>Hello</p></body></html>"
doc = parse_html(html)
tree = print_dom_tree(doc)
assert "<html>" in tree
assert "<body>" in tree
assert "<p>" in tree
assert "Hello" in tree
def test_print_dom_tree_indentation(self):
"""Test that tree has proper indentation."""
html = "<html><body><div><p>Nested</p></div></body></html>"
doc = parse_html(html)
tree = print_dom_tree(doc)
# Should have increasing indentation
lines = tree.split('\n')
# Find the nested <p> line - should be more indented than <div>
p_line = [l for l in lines if '<p>' in l][0]
div_line = [l for l in lines if '<div>' in l][0]
p_line = [line for line in lines if '<p>' in line][0]
div_line = [line for line in lines if '<div>' in line][0]
# Count leading spaces
p_indent = len(p_line) - len(p_line.lstrip())
div_indent = len(div_line) - len(div_line.lstrip())
assert p_indent > div_indent
def test_print_dom_tree_max_depth(self):
"""Test that max_depth limits tree traversal."""
html = "<html><body><div><div><div><p>Deep</p></div></div></div></body></html>"
doc = parse_html(html)
tree_shallow = print_dom_tree(doc, max_depth=2)
tree_deep = print_dom_tree(doc, max_depth=10)
# Shallow should be shorter
assert len(tree_shallow) < len(tree_deep)
assert "..." in tree_shallow
def test_generate_dot_graph_text_escaping(self):
"""Test that special characters in text are escaped."""
html = '<html><body><p>Text with "quotes" and newlines\n</p></body></html>'
doc = parse_html(html)
dot = generate_dot_graph(doc)
# Should have escaped quotes
assert '\\"' in dot or 'quotes' in dot
# Should not have raw newlines breaking the DOT format
@ -129,14 +128,14 @@ class TestDOMGraph:
for line in lines:
if line.strip():
assert not line.strip().startswith('"') or line.strip().endswith(';') or line.strip().endswith(']')
def test_generate_dot_graph_long_text_truncation(self):
"""Test that very long text nodes are truncated."""
long_text = "A" * 100
html = f"<html><body><p>{long_text}</p></body></html>"
doc = parse_html(html)
dot = generate_dot_graph(doc)
# Should contain truncation marker
assert "..." in dot

View file

@ -1,8 +1,6 @@
"""Tests for DOM graph page rendering."""
import pytest
from src.templates import render_dom_graph_page
from pathlib import Path
import tempfile
import os
@ -14,10 +12,10 @@ class TestDOMGraphPage:
with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f:
f.write('<svg><circle cx="50" cy="50" r="40"/></svg>')
temp_path = f.name
try:
html = render_dom_graph_page(temp_path)
assert html
assert "DOM" in html
assert "Visualization" in html or "Graph" in html
@ -26,17 +24,17 @@ class TestDOMGraphPage:
assert temp_path in html # Should show file path
finally:
os.unlink(temp_path)
def test_render_dom_graph_page_dot(self):
"""Test rendering page with DOT graph."""
# Create temporary DOT file
with tempfile.NamedTemporaryFile(mode='w', suffix='.dot', delete=False) as f:
f.write('digraph G { A -> B; }')
temp_path = f.name
try:
html = render_dom_graph_page(temp_path)
assert html
assert "digraph G" in html
# HTML escapes -> as &gt;
@ -44,23 +42,23 @@ class TestDOMGraphPage:
assert "Graphviz" in html # Should suggest installing graphviz
finally:
os.unlink(temp_path)
def test_render_dom_graph_page_missing_file(self):
"""Test error handling for missing file."""
html = render_dom_graph_page("/nonexistent/path/to/graph.svg")
assert html
assert "error" in html.lower() or "not found" in html.lower()
def test_render_dom_graph_page_has_legend(self):
"""Test that SVG page includes color legend."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f:
f.write('<svg><rect/></svg>')
temp_path = f.name
try:
html = render_dom_graph_page(temp_path)
# Should have legend explaining colors
assert 'legend' in html.lower() or 'color' in html.lower()
finally:

View file

@ -1,8 +1,7 @@
"""Tests for Frame and content loading."""
import pytest
from unittest.mock import Mock, patch
from src.browser.tab import Frame, Tab
from src.browser.tab import Tab
from src.network.url import URL
@ -10,66 +9,66 @@ class TestFrame:
@patch('src.browser.tab.http.request')
def test_frame_load_success(self, mock_request):
mock_request.return_value = (200, "text/html", b"<html><body>Test</body></html>")
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
frame = tab.main_frame
url = URL("http://example.com")
frame.load(url)
assert frame.document is not None
assert frame.document.tag == "html"
assert tab.current_url == url
@patch('src.browser.tab.http.request')
def test_frame_load_404(self, mock_request):
mock_request.return_value = (404, "text/html", b"Not Found")
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
frame = tab.main_frame
url = URL("http://example.com/missing")
frame.load(url)
# Should create error document
assert frame.document is not None
# Error message in document
text = frame.document.children[0].children[0].text if frame.document.children else ""
assert "404" in text or "Error" in text
@patch('src.browser.tab.http.request')
def test_frame_load_network_error(self, mock_request):
mock_request.side_effect = Exception("Network unreachable")
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
frame = tab.main_frame
url = URL("http://unreachable.example.com")
frame.load(url)
# Should create error document
assert frame.document is not None
text = frame.document.children[0].children[0].text if frame.document.children else ""
assert "Error" in text or "unreachable" in text
@patch('src.browser.tab.http.request')
def test_frame_load_utf8_decode(self, mock_request):
mock_request.return_value = (200, "text/html", "<html><body>Héllo Wörld</body></html>".encode('utf-8'))
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
frame = tab.main_frame
url = URL("http://example.com")
frame.load(url)
assert frame.document is not None
# Should handle UTF-8 characters
text = frame.document.children[0].children[0].text

View file

@ -1,6 +1,5 @@
"""Tests for HTML parsing functionality."""
import pytest
from src.parser.html import parse_html, Text, Element
@ -18,70 +17,70 @@ class TestParseHTML:
def test_parse_simple_text(self):
html = "<html><body>Hello World</body></html>"
root = parse_html(html)
assert isinstance(root, Element)
assert root.tag == "html"
assert len(root.children) == 1
body = root.children[0]
assert body.tag == "body"
texts = collect_text(body)
joined = " ".join(texts)
assert "Hello World" in joined
def test_parse_strips_tags(self):
html = "<html><body><p>Hello</p><div>World</div></body></html>"
root = parse_html(html)
body = root.children[0]
joined = " ".join(collect_text(body))
assert "Hello" in joined
assert "World" in joined
def test_parse_removes_script_tags(self):
html = "<html><body>Visible<script>alert('bad')</script>Text</body></html>"
root = parse_html(html)
body = root.children[0]
joined = " ".join(collect_text(body))
assert "Visible" in joined
assert "Text" in joined
assert "alert" not in joined
assert "script" not in joined.lower()
def test_parse_removes_style_tags(self):
html = "<html><body>Text<style>body{color:red;}</style>More</body></html>"
root = parse_html(html)
body = root.children[0]
joined = " ".join(collect_text(body))
assert "Text" in joined
assert "More" in joined
assert "color" not in joined
def test_parse_decodes_entities(self):
html = "<html><body>&lt;div&gt; &amp; &quot;test&quot;</body></html>"
root = parse_html(html)
body = root.children[0]
joined = " ".join(collect_text(body))
assert "<div>" in joined
assert "&" in joined
assert '"test"' in joined
def test_parse_normalizes_whitespace(self):
html = "<html><body>Hello \n\n World</body></html>"
root = parse_html(html)
body = root.children[0]
joined = " ".join(collect_text(body))
# Multiple whitespace should be collapsed
assert "Hello World" in joined
def test_parse_empty_document(self):
html = "<html><body></body></html>"
root = parse_html(html)
assert isinstance(root, Element)
assert root.tag == "html"
body = root.children[0]

View file

@ -1,7 +1,7 @@
"""Tests for HTTP functionality."""
import pytest
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import Mock, patch
from src.network.url import URL
from src.network import http
@ -16,18 +16,18 @@ class TestHTTPRequest:
mock_response.reason = "OK"
mock_response.getheader.return_value = "text/html"
mock_response.read.return_value = b"<html>Hello</html>"
mock_conn.getresponse.return_value = mock_response
mock_conn_class.return_value = mock_conn
# Test
url = URL("http://example.com/page")
status, content_type, body = http.request(url)
assert status == 200
assert content_type == "text/html"
assert body == b"<html>Hello</html>"
@patch('src.network.http.http.client.HTTPSConnection')
def test_https_request(self, mock_conn_class):
# Setup mock
@ -37,18 +37,18 @@ class TestHTTPRequest:
mock_response.reason = "OK"
mock_response.getheader.return_value = "text/html"
mock_response.read.return_value = b"Secure content"
mock_conn.getresponse.return_value = mock_response
mock_conn_class.return_value = mock_conn
# Test
url = URL("https://example.com")
status, content_type, body = http.request(url)
assert status == 200
assert b"Secure" in body
mock_conn_class.assert_called_once()
@patch('src.network.http.http.client.HTTPConnection')
def test_http_request_404(self, mock_conn_class):
# Setup mock
@ -58,16 +58,16 @@ class TestHTTPRequest:
mock_response.reason = "Not Found"
mock_response.getheader.return_value = "text/html"
mock_response.read.return_value = b"<html>Not Found</html>"
mock_conn.getresponse.return_value = mock_response
mock_conn_class.return_value = mock_conn
# Test
url = URL("http://example.com/missing")
status, content_type, body = http.request(url)
assert status == 404
@patch('src.network.http.http.client.HTTPConnection')
def test_http_request_with_user_agent(self, mock_conn_class):
# Setup mock
@ -77,20 +77,20 @@ class TestHTTPRequest:
mock_response.reason = "OK"
mock_response.getheader.return_value = "text/html"
mock_response.read.return_value = b"content"
mock_conn.getresponse.return_value = mock_response
mock_conn_class.return_value = mock_conn
# Test
url = URL("http://example.com")
http.request(url)
# Verify User-Agent header was sent
call_args = mock_conn.request.call_args
headers = call_args[1]['headers']
assert 'User-Agent' in headers
assert 'Bowser' in headers['User-Agent']
@patch('src.network.http.http.client.HTTPConnection')
def test_http_redirect_301(self, mock_conn_class):
"""Test following 301 permanent redirect."""
@ -104,7 +104,7 @@ class TestHTTPRequest:
"Location": "http://example.com/new-page"
}.get(header, default)
mock_response_redirect.read.return_value = b"<html>Redirect</html>"
# Setup mock for second request (final response)
mock_response_final = Mock()
mock_response_final.status = 200
@ -113,18 +113,18 @@ class TestHTTPRequest:
"Content-Type": "text/html",
}.get(header, default)
mock_response_final.read.return_value = b"<html>Final content</html>"
mock_conn.getresponse.side_effect = [mock_response_redirect, mock_response_final]
mock_conn_class.return_value = mock_conn
# Test
url = URL("http://example.com/old-page")
status, content_type, body = http.request(url)
assert status == 200
assert body == b"<html>Final content</html>"
assert mock_conn.request.call_count == 2
@patch('src.network.http.http.client.HTTPConnection')
def test_http_redirect_302(self, mock_conn_class):
"""Test following 302 temporary redirect."""
@ -138,7 +138,7 @@ class TestHTTPRequest:
"Location": "http://example.com/temp-page"
}.get(header, default)
mock_response_redirect.read.return_value = b"<html>Redirect</html>"
# Setup mock for second request (final response)
mock_response_final = Mock()
mock_response_final.status = 200
@ -147,17 +147,17 @@ class TestHTTPRequest:
"Content-Type": "text/html",
}.get(header, default)
mock_response_final.read.return_value = b"<html>Temp content</html>"
mock_conn.getresponse.side_effect = [mock_response_redirect, mock_response_final]
mock_conn_class.return_value = mock_conn
# Test
url = URL("http://example.com/old-page")
status, content_type, body = http.request(url)
assert status == 200
assert body == b"<html>Temp content</html>"
@patch('src.network.http.http.client.HTTPConnection')
def test_http_redirect_no_location(self, mock_conn_class):
"""Test handling of redirect without Location header."""
@ -170,18 +170,18 @@ class TestHTTPRequest:
"Content-Type": "text/html",
}.get(header, default)
mock_response.read.return_value = b"<html>Redirect</html>"
mock_conn.getresponse.return_value = mock_response
mock_conn_class.return_value = mock_conn
# Test
url = URL("http://example.com/page")
status, content_type, body = http.request(url)
# Should return the redirect response if no Location header
assert status == 302
assert body == b"<html>Redirect</html>"
@patch('src.network.http.http.client.HTTPConnection')
def test_http_max_redirects(self, mock_conn_class):
"""Test that max redirects limit is enforced."""
@ -194,10 +194,10 @@ class TestHTTPRequest:
"Location": "http://example.com/redirect-loop"
}.get(header, default)
mock_response.read.return_value = b""
mock_conn.getresponse.return_value = mock_response
mock_conn_class.return_value = mock_conn
# Test with max_redirects=2
url = URL("http://example.com/page")
with pytest.raises(Exception, match="Too many redirects"):

View file

@ -1,8 +1,9 @@
# ruff: noqa: E402
"""Tests for layout components."""
import pytest
import sys
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import MagicMock
# Mock skia before importing layout modules
mock_skia = MagicMock()
@ -21,19 +22,19 @@ from src.parser.html import Element, Text
class TestLayoutLine:
"""Tests for LayoutLine class."""
def test_layout_line_creation(self):
line = LayoutLine("Hello", 20, 100, 14)
assert line.text == "Hello"
assert line.x == 20
assert line.y == 100
assert line.font_size == 14
def test_layout_line_with_char_positions(self):
char_positions = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0]
line = LayoutLine("Hello", 20, 100, 14, char_positions)
assert line.char_positions == char_positions
def test_layout_line_height(self):
line = LayoutLine("Test", 0, 0, 14)
# Height should be based on linespace (font_size * 1.4)
@ -42,13 +43,13 @@ class TestLayoutLine:
class TestLayoutBlock:
"""Tests for LayoutBlock class."""
def test_layout_block_creation(self):
block = LayoutBlock("p")
assert block.tag == "p"
assert block.block_type == "block"
assert block.lines == []
def test_layout_block_with_type(self):
block = LayoutBlock("li", "list-item")
assert block.block_type == "list-item"
@ -56,14 +57,14 @@ class TestLayoutBlock:
class TestDocumentLayout:
"""Tests for DocumentLayout class."""
def test_document_layout_creation(self):
node = Element("html")
layout = DocumentLayout(node)
assert layout.node is node
assert layout.blocks == []
assert layout.lines == []
def test_document_layout_finds_body(self):
# Create HTML structure: html > body > p
html = Element("html")
@ -72,46 +73,46 @@ class TestDocumentLayout:
p.children = [Text("Hello world")]
body.children = [p]
html.children = [body]
layout = DocumentLayout(html)
lines = layout.layout(800)
assert len(lines) > 0
assert any("Hello world" in line.text for line in lines)
def test_document_layout_returns_empty_without_body(self):
node = Element("div")
node.children = []
layout = DocumentLayout(node)
lines = layout.layout(800)
assert lines == []
def test_document_layout_handles_headings(self):
body = Element("body")
h1 = Element("h1")
h1.children = [Text("Title")]
body.children = [h1]
layout = DocumentLayout(body)
lines = layout.layout(800)
assert len(lines) == 1
assert lines[0].text == "Title"
assert lines[0].font_size == 24 # h1 font size
def test_document_layout_handles_paragraphs(self):
body = Element("body")
p = Element("p")
p.children = [Text("Paragraph text")]
body.children = [p]
layout = DocumentLayout(body)
lines = layout.layout(800)
assert len(lines) == 1
assert lines[0].text == "Paragraph text"
assert lines[0].font_size == 14 # p font size
def test_document_layout_handles_lists(self):
body = Element("body")
ul = Element("ul")
@ -119,35 +120,40 @@ class TestDocumentLayout:
li.children = [Text("List item")]
ul.children = [li]
body.children = [ul]
layout = DocumentLayout(body)
lines = layout.layout(800)
assert len(lines) == 1
assert "" in lines[0].text # Bullet prefix
assert "List item" in lines[0].text
def test_document_layout_word_wrapping(self):
body = Element("body")
p = Element("p")
# Long text that should wrap
p.children = [Text("This is a very long paragraph that should wrap to multiple lines when the width is narrow enough")]
p.children = [
Text(
"This is a very long paragraph that should wrap to multiple lines "
"when the width is narrow enough"
)
]
body.children = [p]
layout = DocumentLayout(body)
lines = layout.layout(200) # Narrow width to force wrapping
assert len(lines) > 1 # Should wrap to multiple lines
def test_document_layout_char_positions(self):
body = Element("body")
p = Element("p")
p.children = [Text("Hello")]
body.children = [p]
layout = DocumentLayout(body)
lines = layout.layout(800)
assert len(lines) == 1
# char_positions should have len(text) + 1 entries (including start at 0)
assert len(lines[0].char_positions) == 6 # "Hello" = 5 chars + 1
@ -156,20 +162,20 @@ class TestDocumentLayout:
class TestBlockLayout:
"""Tests for BlockLayout class."""
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
def test_block_layout_stores_dimensions(self):
node = Element("div")
layout = BlockLayout(node)
@ -185,13 +191,13 @@ class TestBlockLayout:
class TestLineLayout:
"""Tests for LineLayout class."""
def test_line_layout_creation(self):
layout = LineLayout()
assert layout.words == []
assert layout.x == 0
assert layout.y == 0
def test_line_layout_add_word(self):
layout = LineLayout()
layout.add_word("Hello", 0, 14)
@ -201,19 +207,19 @@ class TestLineLayout:
class TestTextLayout:
"""Tests for TextLayout class."""
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_layout_returns_width(self):
node = Text("Test")
layout = TextLayout(node, "Test")
width = layout.layout(14)
assert width > 0
def test_text_layout_dimensions(self):
node = Text("Hi")
layout = TextLayout(node, "Hi")
@ -225,20 +231,20 @@ class TestTextLayout:
class TestInlineLayout:
"""Tests for InlineLayout class."""
def test_inline_layout_creation(self):
node = Text("Hello")
layout = InlineLayout(node)
assert layout.node is node
assert layout.children == []
def test_inline_layout_add_word(self):
node = Text("Hello World")
layout = InlineLayout(node)
layout.add_word("Hello", 14)
layout.add_word("World", 14)
assert len(layout.children) == 2
def test_inline_layout_layout(self):
node = Text("Hello World")
layout = InlineLayout(node)
@ -251,7 +257,7 @@ class TestInlineLayout:
class TestBuildBlockLayout:
"""Tests for build_block_layout factory function."""
def test_build_block_layout_from_element(self):
node = Element("p")
node.children = [Text("Test paragraph")]
@ -259,13 +265,13 @@ class TestBuildBlockLayout:
assert result is not None
assert result.node is node
assert result.font_size == 14
def test_build_block_layout_heading(self):
node = Element("h1")
node.children = [Text("Heading")]
result = build_block_layout(node, font_size=24)
assert result.font_size == 24
def test_build_block_layout_list_item(self):
node = Element("li")
node.children = [Text("Item")]

View file

@ -1,6 +1,5 @@
"""Tests for HTML parsing."""
import pytest
from src.parser.html import Text, Element, print_tree
@ -9,22 +8,22 @@ class TestHTMLElements:
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)
@ -37,14 +36,14 @@ class TestPrintTree:
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

View file

@ -1,8 +1,9 @@
# ruff: noqa: E402
"""Tests for rendering primitives."""
import pytest
import sys
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import MagicMock
# Mock skia before importing render modules
mock_skia = MagicMock()
@ -20,7 +21,7 @@ from src.render.fonts import FontCache, get_font, measure_text, linespace
class TestPaintCommands:
"""Tests for paint command base class."""
def test_paint_command_creation(self):
cmd = PaintCommand((0, 0, 100, 100))
assert cmd.rect == (0, 0, 100, 100)
@ -28,18 +29,18 @@ class TestPaintCommands:
class TestDrawText:
"""Tests for DrawText paint command."""
def test_draw_text_creation(self):
cmd = DrawText(10, 20, "Hello", 14)
assert cmd.x == 10
assert cmd.y == 20
assert cmd.text == "Hello"
assert cmd.font_size == 14
def test_draw_text_with_color(self):
cmd = DrawText(0, 0, "Test", 12, color=0xFF0000)
assert cmd.color == 0xFF0000
def test_draw_text_rect_property(self):
cmd = DrawText(10, 20, "Hi", 14)
# Should have a rect based on position and size
@ -49,15 +50,15 @@ class TestDrawText:
class TestDrawRect:
"""Tests for DrawRect paint command."""
def test_draw_rect_creation(self):
cmd = DrawRect(10, 20, 110, 70, color=0x000000)
assert cmd.rect == (10, 20, 110, 70)
def test_draw_rect_with_color(self):
cmd = DrawRect(0, 0, 50, 50, color=0x00FF00)
assert cmd.color == 0x00FF00
def test_draw_rect_fill_mode(self):
cmd_fill = DrawRect(0, 0, 50, 50, color=0x000000, fill=True)
cmd_stroke = DrawRect(0, 0, 50, 50, color=0x000000, fill=False)
@ -67,48 +68,48 @@ class TestDrawRect:
class TestDisplayList:
"""Tests for DisplayList class."""
def test_display_list_creation(self):
dl = DisplayList()
assert len(dl.commands) == 0
def test_display_list_append(self):
dl = DisplayList()
cmd = DrawText(0, 0, "Test", 14)
dl.append(cmd)
assert len(dl.commands) == 1
assert dl.commands[0] is cmd
def test_display_list_len(self):
dl = DisplayList()
dl.append(DrawText(0, 0, "A", 14))
dl.append(DrawText(0, 20, "B", 14))
assert len(dl) == 2
def test_display_list_iteration(self):
dl = DisplayList()
cmd1 = DrawText(0, 0, "A", 14)
cmd2 = DrawText(0, 20, "B", 14)
dl.append(cmd1)
dl.append(cmd2)
items = list(dl)
assert items == [cmd1, cmd2]
class TestCompositedLayer:
"""Tests for composited layer."""
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")
@ -118,19 +119,19 @@ class TestCompositedLayer:
class TestFontCache:
"""Tests for FontCache singleton."""
def test_font_cache_singleton(self):
cache1 = FontCache()
cache2 = FontCache()
assert cache1 is cache2
def test_font_cache_get_font(self):
cache = FontCache()
font1 = cache.get_font(14)
font2 = cache.get_font(14)
# Should return the same cached font
assert font1 is font2
def test_font_cache_different_sizes(self):
cache = FontCache()
font14 = cache.get_font(14)
@ -143,30 +144,30 @@ class TestFontCache:
class TestFontFunctions:
"""Tests for font module functions."""
def test_get_font(self):
font = get_font(14)
assert font is not None
def test_get_font_caching(self):
font1 = get_font(16)
font2 = get_font(16)
assert font1 is font2
def test_measure_text(self):
width = measure_text("Hello", 14)
assert width > 0
assert isinstance(width, (int, float))
def test_measure_text_empty(self):
width = measure_text("", 14)
assert width == 0
def test_linespace(self):
space = linespace(14)
# Should be font_size * 1.4 (typical line height)
assert space == pytest.approx(14 * 1.4, rel=0.1)
def test_linespace_different_sizes(self):
space14 = linespace(14)
space20 = linespace(20)

View file

@ -1,6 +1,5 @@
"""Tests for template rendering."""
import pytest
from src.templates import render_template, render_error_page, render_startpage
@ -8,51 +7,51 @@ class TestTemplateRendering:
def test_render_startpage(self):
"""Test rendering the startpage template."""
html = render_startpage()
assert html
assert "Bowser" in html
assert "Welcome" in html
assert "<!DOCTYPE html>" in html
def test_render_startpage_has_version(self):
"""Test that startpage includes version."""
html = render_startpage()
assert "0.0.1" in html
def test_render_error_404(self):
"""Test rendering 404 error page."""
html = render_error_page(404, "http://example.com/missing")
assert html
assert "404" in html
assert "example.com/missing" in html
assert "Not Found" in html
def test_render_error_500(self):
"""Test rendering 500 error page."""
html = render_error_page(500, "http://example.com/error")
assert html
assert "500" in html
assert "Server Error" in html
def test_render_error_network(self):
"""Test rendering network error page."""
html = render_error_page(0, "http://example.com", "Connection refused")
assert html
assert "Network Error" in html
assert "Connection refused" in html
def test_render_error_with_custom_context(self):
"""Test error page with custom error message."""
html = render_error_page(404, "http://example.com", "Custom error message")
assert "Custom error message" in html
def test_render_template_with_context(self):
"""Test rendering template with custom context."""
html = render_template("startpage.html", version="1.0.0")
assert "1.0.0" in html

View file

@ -1,6 +1,5 @@
"""Tests for URL parsing and resolution."""
import pytest
from src.network.url import URL
@ -8,33 +7,33 @@ 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")

View file

@ -1,6 +1,5 @@
"""Tests for URL normalization."""
import pytest
from src.browser.browser import Browser
@ -8,61 +7,61 @@ class TestURLNormalization:
def setup_method(self):
"""Create a browser instance for each test."""
self.browser = Browser()
def test_normalize_url_with_https(self):
"""Test that URLs with https:// protocol are unchanged."""
url = "https://example.com"
normalized = self.browser._normalize_url(url)
assert normalized == "https://example.com"
def test_normalize_url_with_http(self):
"""Test that URLs with http:// protocol are unchanged."""
url = "http://example.com"
normalized = self.browser._normalize_url(url)
assert normalized == "http://example.com"
def test_normalize_url_without_protocol(self):
"""Test that URLs without protocol get https:// added."""
url = "example.com"
normalized = self.browser._normalize_url(url)
assert normalized == "https://example.com"
def test_normalize_url_with_path(self):
"""Test that URLs with path but no protocol get https:// added."""
url = "example.com/path/to/page"
normalized = self.browser._normalize_url(url)
assert normalized == "https://example.com/path/to/page"
def test_normalize_url_with_about(self):
"""Test that about: URLs are not modified."""
url = "about:startpage"
normalized = self.browser._normalize_url(url)
assert normalized == "about:startpage"
def test_normalize_url_strips_whitespace(self):
"""Test that leading/trailing whitespace is stripped."""
url = " example.com "
normalized = self.browser._normalize_url(url)
assert normalized == "https://example.com"
def test_normalize_url_with_query_string(self):
"""Test that URLs with query strings work correctly."""
url = "example.com/search?q=test"
normalized = self.browser._normalize_url(url)
assert normalized == "https://example.com/search?q=test"
def test_normalize_url_with_subdomain(self):
"""Test that subdomains work correctly."""
url = "www.example.com"
normalized = self.browser._normalize_url(url)
assert normalized == "https://www.example.com"
def test_normalize_url_with_port(self):
"""Test that ports are preserved."""
url = "example.com:8080"
normalized = self.browser._normalize_url(url)
assert normalized == "https://example.com:8080"
def test_normalize_url_localhost(self):
"""Test that localhost URLs work correctly."""
url = "localhost:3000"

17
uv.lock
View file

@ -49,7 +49,7 @@ dependencies = [
{ name = "skia-python" },
]
[package.optional-dependencies]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "mypy" },
@ -60,16 +60,19 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=25.0" },
{ name = "jinja2", specifier = ">=3.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.9.0" },
{ name = "pygobject", specifier = ">=3.54.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" },
{ name = "skia-python", specifier = ">=87.9" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=25.0" },
{ name = "mypy", specifier = ">=1.9.0" },
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.9.0" },
]
[[package]]
name = "click"