mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
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:
parent
4b3ba9144d
commit
c9ef5e5c44
29 changed files with 989 additions and 625 deletions
345
.github/skills/skills.md
vendored
Normal file
345
.github/skills/skills.md
vendored
Normal 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 ad‑hoc 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 project’s 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 end‑to‑end
|
||||
- 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 project’s test command instead)
|
||||
- Running one‑off 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** over‑explain 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 project’s on‑switch.”
|
||||
|
||||
---
|
||||
|
||||
## 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 project’s 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 project’s 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 project’s 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 project’s 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 project’s 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 project’s 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.
|
||||
|
||||
4
main.py
4
main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# ruff: noqa: E402
|
||||
"""Browser entry and orchestration."""
|
||||
|
||||
import gi
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 >
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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><div> & "test"</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]
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
17
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue