Fix screenshot affecting terminal state in open sessions
- Add get_screen_snapshot() method that doesn't mutate terminal state - Use change counter for reliable activity detection instead of dirty flag - Update screenshot handler to use non-mutating snapshot method - Refactor tests to use shared fixtures and reduce duplication - Update copilot-instructions.md with detailed Makefile usage
This commit is contained in:
@@ -1,4 +1,51 @@
|
|||||||
Use the Makefile to run linting, testing, and builds.
|
# Copilot Development Instructions
|
||||||
Aim for good test coverage.
|
|
||||||
Review tests periodically with a view to consolidate/parameterize and remove redundancy.
|
## Makefile Usage (MANDATORY)
|
||||||
Debug issues systematically. Search for and review documentation as needed.
|
|
||||||
|
Always use the Makefile for development tasks. Never run raw `pytest`, `ruff`, or `bun` commands directly.
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
| Task | Command | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| Run tests | `make test` | Run pytest |
|
||||||
|
| Run linter | `make lint` | Run ruff linter |
|
||||||
|
| Format code | `make format` | Auto-format with ruff |
|
||||||
|
| Full check | `make check` | Run lint + coverage |
|
||||||
|
| Coverage report | `make coverage` | Run pytest with coverage |
|
||||||
|
| Build frontend | `make build` | TypeScript typecheck + bundle |
|
||||||
|
| Quick frontend build | `make build-fast` | Bundle without typecheck |
|
||||||
|
| Watch mode | `make bundle-watch` | Frontend dev with auto-rebuild |
|
||||||
|
| Install dev deps | `make install-dev` | Install package + dev dependencies |
|
||||||
|
| Clean build | `make build-all` | Full reproducible build from scratch |
|
||||||
|
| See all targets | `make help` | Show all available commands |
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
1. **Before making changes**: Run `make check` to establish baseline
|
||||||
|
2. **After making changes**: Run `make check` to verify no regressions
|
||||||
|
3. **Frontend changes**: Use `make build` or `make build-fast`
|
||||||
|
4. **Full rebuild**: Use `make build-all` (cleans everything first)
|
||||||
|
|
||||||
|
### Clean Targets
|
||||||
|
|
||||||
|
- `make clean` - Remove Python cache files only
|
||||||
|
- `make bundle-clean` - Remove frontend build artifacts (node_modules, etc.)
|
||||||
|
- `make clean-all` - Remove everything
|
||||||
|
|
||||||
|
### Version Management
|
||||||
|
|
||||||
|
- `make bump-patch` - Bump patch version and create git tag
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- Aim for good test coverage
|
||||||
|
- Review tests periodically to consolidate/parameterize and remove redundancy
|
||||||
|
- Use fixtures from `tests/conftest.py` instead of duplicating setup code
|
||||||
|
- Prefer parameterized tests for similar test cases
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Do not use heredocs or random shell commands
|
||||||
|
- Prefer `make` and ecosystem tools (pip, bun) over manual operations
|
||||||
|
- Debug issues systematically - search for and review documentation as needed
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all bundle bundle-watch bundle-clean typecheck bump-patch
|
.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all build-fast bundle bundle-watch bundle-clean typecheck bump-patch push
|
||||||
|
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
PIP ?= $(PYTHON) -m pip
|
PIP ?= $(PYTHON) -m pip
|
||||||
@@ -6,92 +6,63 @@ PIP ?= $(PYTHON) -m pip
|
|||||||
# Static assets
|
# Static assets
|
||||||
STATIC_JS_DIR = src/webterm/static/js
|
STATIC_JS_DIR = src/webterm/static/js
|
||||||
TERMINAL_JS = $(STATIC_JS_DIR)/terminal.js
|
TERMINAL_JS = $(STATIC_JS_DIR)/terminal.js
|
||||||
TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts
|
|
||||||
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
|
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
|
||||||
|
|
||||||
help:
|
help: ## Show this help
|
||||||
@echo "Build targets:"
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
@echo " build-all - Full reproducible build (clean + deps + bundle + install)"
|
|
||||||
@echo " build - Build frontend (typecheck + bundle)"
|
|
||||||
@echo " build-fast - Build frontend without typecheck"
|
|
||||||
@echo " bundle - Alias for build"
|
|
||||||
@echo " bundle-watch - Watch mode for development"
|
|
||||||
@echo " typecheck - Run TypeScript type checking"
|
|
||||||
@echo ""
|
|
||||||
@echo "Python targets:"
|
|
||||||
@echo " install - Install package in editable mode"
|
|
||||||
@echo " install-dev - Install with dev dependencies"
|
|
||||||
@echo " lint - Run ruff linter"
|
|
||||||
@echo " format - Format code with ruff"
|
|
||||||
@echo " test - Run pytest"
|
|
||||||
@echo " coverage - Run pytest with coverage"
|
|
||||||
@echo " check - Run lint + coverage"
|
|
||||||
@echo ""
|
|
||||||
@echo "Clean targets:"
|
|
||||||
@echo " clean - Remove Python cache files"
|
|
||||||
@echo " bundle-clean - Remove frontend build artifacts"
|
|
||||||
@echo " clean-all - Remove everything (clean + bundle-clean)"
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Full reproducible build
|
# Full reproducible build
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
build-all: clean-all node_modules build install-dev check
|
build-all: clean-all node_modules build install-dev check ## Full reproducible build (clean + deps + bundle + install)
|
||||||
@echo "Build complete!"
|
@echo "Build complete!"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Python targets
|
# Python targets
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
install:
|
install: ## Install package in editable mode
|
||||||
$(PIP) install -e .
|
$(PIP) install -e .
|
||||||
|
|
||||||
install-dev:
|
install-dev: install ## Install with dev dependencies
|
||||||
$(PIP) install -e .
|
|
||||||
$(PIP) install pytest pytest-asyncio pytest-cov pytest-timeout ruff
|
$(PIP) install pytest pytest-asyncio pytest-cov pytest-timeout ruff
|
||||||
|
|
||||||
lint:
|
lint: ## Run ruff linter
|
||||||
ruff check src tests
|
ruff check src tests
|
||||||
|
|
||||||
format:
|
format: ## Format code with ruff
|
||||||
ruff format src tests
|
ruff format src tests
|
||||||
|
|
||||||
test:
|
test: ## Run pytest
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
coverage:
|
coverage: ## Run pytest with coverage
|
||||||
pytest --cov=src/webterm --cov-report=term-missing
|
pytest --cov=src/webterm --cov-report=term-missing
|
||||||
|
|
||||||
check: lint coverage
|
check: lint coverage ## Run lint + coverage
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Frontend build targets (requires Bun: https://bun.sh)
|
# Frontend build targets (requires Bun: https://bun.sh)
|
||||||
# All frontend commands MUST go through bun run to ensure consistency
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Install node dependencies (creates bun.lock if missing)
|
|
||||||
node_modules: package.json
|
node_modules: package.json
|
||||||
bun install
|
bun install
|
||||||
@touch node_modules
|
@touch node_modules
|
||||||
|
|
||||||
# TypeScript type checking
|
typecheck: node_modules ## Run TypeScript type checking
|
||||||
typecheck: node_modules
|
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
|
|
||||||
# Main build target - typecheck + bundle + copy WASM
|
build: node_modules ## Build frontend (typecheck + bundle)
|
||||||
build: node_modules
|
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Fast build without typecheck (for rapid iteration)
|
build-fast: node_modules ## Build frontend without typecheck
|
||||||
build-fast: node_modules
|
|
||||||
bun run build:fast
|
bun run build:fast
|
||||||
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
|
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
|
||||||
|
|
||||||
# Alias for build
|
bundle: build ## Alias for build
|
||||||
bundle: build
|
|
||||||
|
|
||||||
# Watch mode for development
|
bundle-watch: node_modules ## Watch mode for frontend development
|
||||||
bundle-watch: node_modules
|
|
||||||
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
|
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
|
||||||
bun run watch
|
bun run watch
|
||||||
|
|
||||||
@@ -99,24 +70,29 @@ bundle-watch: node_modules
|
|||||||
# Clean targets
|
# Clean targets
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
clean:
|
clean: ## Remove Python cache files
|
||||||
rm -rf .pytest_cache .coverage htmlcov .ruff_cache __pycache__ src/**/__pycache__
|
rm -rf .pytest_cache .coverage htmlcov .ruff_cache __pycache__ src/**/__pycache__
|
||||||
|
|
||||||
bundle-clean:
|
bundle-clean: ## Remove frontend build artifacts
|
||||||
rm -rf node_modules bun.lock $(TERMINAL_JS) $(GHOSTTY_WASM)
|
rm -rf node_modules bun.lock $(TERMINAL_JS) $(GHOSTTY_WASM)
|
||||||
|
|
||||||
clean-all: clean bundle-clean
|
clean-all: clean bundle-clean ## Remove everything (clean + bundle-clean)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Version management
|
# Version management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Bump patch version (e.g., 0.5.3 -> 0.5.4)
|
bump-patch: ## Bump patch version and create git tag
|
||||||
bump-patch:
|
|
||||||
@OLD=$$(grep -Po '(?<=^version = ")[^"]+' pyproject.toml); \
|
@OLD=$$(grep -Po '(?<=^version = ")[^"]+' pyproject.toml); \
|
||||||
MAJOR=$$(echo $$OLD | cut -d. -f1); \
|
MAJOR=$$(echo $$OLD | cut -d. -f1); \
|
||||||
MINOR=$$(echo $$OLD | cut -d. -f2); \
|
MINOR=$$(echo $$OLD | cut -d. -f2); \
|
||||||
PATCH=$$(echo $$OLD | cut -d. -f3); \
|
PATCH=$$(echo $$OLD | cut -d. -f3); \
|
||||||
NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
|
NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
|
||||||
sed -i "s/^version = \"$$OLD\"/version = \"$$NEW\"/" pyproject.toml; \
|
sed -i "s/^version = \"$$OLD\"/version = \"$$NEW\"/" pyproject.toml; \
|
||||||
echo "Bumped version: $$OLD -> $$NEW"
|
git add pyproject.toml; \
|
||||||
|
git commit -m "Bump version to $$NEW"; \
|
||||||
|
git tag "v$$NEW"; \
|
||||||
|
echo "Bumped version: $$OLD -> $$NEW (tagged v$$NEW)"
|
||||||
|
|
||||||
|
push: ## Push commits and tags to origin
|
||||||
|
git push origin main --tags
|
||||||
|
|||||||
@@ -554,18 +554,18 @@ class LocalServer:
|
|||||||
if cached_response is not None:
|
if cached_response is not None:
|
||||||
return cached_response
|
return cached_response
|
||||||
|
|
||||||
has_changes = await session_process.get_screen_has_changes() # type: ignore[union-attr]
|
# Use non-mutating snapshot method to avoid affecting terminal state
|
||||||
if not has_changes and cached is not None:
|
|
||||||
cached_response = self._get_cached_screenshot_response(request, route_key)
|
|
||||||
if cached_response is not None:
|
|
||||||
return cached_response
|
|
||||||
|
|
||||||
(
|
(
|
||||||
screen_width,
|
screen_width,
|
||||||
screen_height,
|
screen_height,
|
||||||
screen_buffer,
|
screen_buffer,
|
||||||
_,
|
has_changes,
|
||||||
) = await session_process.get_screen_state() # type: ignore[union-attr]
|
) = await session_process.get_screen_snapshot() # type: ignore[union-attr]
|
||||||
|
|
||||||
|
if not has_changes and cached is not None:
|
||||||
|
cached_response = self._get_cached_screenshot_response(request, route_key)
|
||||||
|
if cached_response is not None:
|
||||||
|
return cached_response
|
||||||
|
|
||||||
def _render_svg() -> str:
|
def _render_svg() -> str:
|
||||||
# Use custom SVG exporter - simpler and more reliable than Rich
|
# Use custom SVG exporter - simpler and more reliable than Rich
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class TerminalSession(Session):
|
|||||||
# Track last known terminal size for reconnection
|
# Track last known terminal size for reconnection
|
||||||
self._last_width = DEFAULT_SCREEN_WIDTH
|
self._last_width = DEFAULT_SCREEN_WIDTH
|
||||||
self._last_height = DEFAULT_SCREEN_HEIGHT
|
self._last_height = DEFAULT_SCREEN_HEIGHT
|
||||||
|
# Change counter for reliable activity detection (monotonically increasing)
|
||||||
|
self._change_counter = 0
|
||||||
|
self._last_snapshot_counter = 0
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -146,6 +149,8 @@ class TerminalSession(Session):
|
|||||||
await loop.run_in_executor(None, self._set_terminal_size, width, height)
|
await loop.run_in_executor(None, self._set_terminal_size, width, height)
|
||||||
# Resize pyte screen to match
|
# Resize pyte screen to match
|
||||||
self._screen.resize(height, width)
|
self._screen.resize(height, width)
|
||||||
|
# Increment change counter since resize changes screen content
|
||||||
|
self._change_counter += 1
|
||||||
|
|
||||||
async def force_redraw(self) -> None:
|
async def force_redraw(self) -> None:
|
||||||
"""Force a terminal redraw by re-sending current size."""
|
"""Force a terminal redraw by re-sending current size."""
|
||||||
@@ -169,6 +174,9 @@ class TerminalSession(Session):
|
|||||||
try:
|
try:
|
||||||
text = data.decode("utf-8", errors="replace")
|
text = data.decode("utf-8", errors="replace")
|
||||||
self._stream.feed(text)
|
self._stream.feed(text)
|
||||||
|
# Increment change counter when screen is modified
|
||||||
|
if self._screen.dirty:
|
||||||
|
self._change_counter += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't let pyte errors crash the session
|
# Don't let pyte errors crash the session
|
||||||
pass
|
pass
|
||||||
@@ -188,14 +196,60 @@ class TerminalSession(Session):
|
|||||||
return [line.rstrip() for line in self._screen.display]
|
return [line.rstrip() for line in self._screen.display]
|
||||||
|
|
||||||
async def get_screen_has_changes(self) -> bool:
|
async def get_screen_has_changes(self) -> bool:
|
||||||
"""Check if the screen has changed since the last snapshot."""
|
"""Check if the screen has changed since the last snapshot.
|
||||||
await self._sync_pyte_to_pty()
|
|
||||||
|
This is a non-mutating read-only check that compares the change counter.
|
||||||
|
"""
|
||||||
async with self._screen_lock:
|
async with self._screen_lock:
|
||||||
return len(self._screen.dirty) > 0
|
return self._change_counter > self._last_snapshot_counter
|
||||||
|
|
||||||
|
async def get_screen_snapshot(self) -> tuple[int, int, list, bool]:
|
||||||
|
"""Get a read-only snapshot of the current screen state for screenshots.
|
||||||
|
|
||||||
|
This method does NOT mutate terminal state - safe for dashboard screenshots.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (width, height, buffer, has_changes) where:
|
||||||
|
- width: screen width in columns
|
||||||
|
- height: screen height in rows
|
||||||
|
- buffer: list of rows, each containing character data with styling
|
||||||
|
- has_changes: True if screen has changed since last snapshot
|
||||||
|
"""
|
||||||
|
async with self._screen_lock:
|
||||||
|
width = self._screen.columns
|
||||||
|
height = self._screen.lines
|
||||||
|
has_changes = self._change_counter > self._last_snapshot_counter
|
||||||
|
# Update the snapshot counter to track what we've seen
|
||||||
|
self._last_snapshot_counter = self._change_counter
|
||||||
|
# Snapshot buffer cells quickly to minimize lock hold time
|
||||||
|
snapshot = [
|
||||||
|
[self._screen.buffer[row][col] for col in range(width)] for row in range(height)
|
||||||
|
]
|
||||||
|
|
||||||
|
buffer = []
|
||||||
|
for row_data in snapshot:
|
||||||
|
row_chars = []
|
||||||
|
for char in row_data:
|
||||||
|
row_chars.append(
|
||||||
|
{
|
||||||
|
"data": char.data if char.data else " ",
|
||||||
|
"fg": char.fg,
|
||||||
|
"bg": char.bg,
|
||||||
|
"bold": char.bold,
|
||||||
|
"italics": char.italics,
|
||||||
|
"underscore": char.underscore,
|
||||||
|
"reverse": char.reverse,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
buffer.append(row_chars)
|
||||||
|
return (width, height, buffer, has_changes)
|
||||||
|
|
||||||
async def get_screen_state(self) -> tuple[int, int, list, bool]:
|
async def get_screen_state(self) -> tuple[int, int, list, bool]:
|
||||||
"""Get the current screen state including dimensions and character buffer.
|
"""Get the current screen state including dimensions and character buffer.
|
||||||
|
|
||||||
|
Note: This method syncs pyte to PTY size and clears dirty flags.
|
||||||
|
For read-only screenshot access, use get_screen_snapshot() instead.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (width, height, buffer, has_changes) where:
|
Tuple of (width, height, buffer, has_changes) where:
|
||||||
- width: screen width in columns
|
- width: screen width in columns
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ def mock_session():
|
|||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.get_screen_has_changes = AsyncMock(return_value=False)
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.get_screen_state = AsyncMock(return_value=(80, 24, [], True))
|
session.get_screen_state = AsyncMock(return_value=(80, 24, [], True))
|
||||||
|
session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], True))
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@@ -95,6 +96,54 @@ def poller() -> Poller:
|
|||||||
return Poller()
|
return Poller()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_poller() -> MagicMock:
|
||||||
|
"""Create a mock Poller for unit tests."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
class DummyAsyncLock:
|
||||||
|
"""A dummy async context manager for replacing locks in tests."""
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dummy_lock() -> DummyAsyncLock:
|
||||||
|
"""Create a dummy async lock for tests."""
|
||||||
|
return DummyAsyncLock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_screen_char():
|
||||||
|
"""Factory for creating mock pyte screen characters."""
|
||||||
|
|
||||||
|
def _make(
|
||||||
|
data: str = " ",
|
||||||
|
fg: int = 0,
|
||||||
|
bg: int = 0,
|
||||||
|
bold: bool = False,
|
||||||
|
italics: bool = False,
|
||||||
|
underscore: bool = False,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> MagicMock:
|
||||||
|
char = MagicMock()
|
||||||
|
char.data = data
|
||||||
|
char.fg = fg
|
||||||
|
char.bg = bg
|
||||||
|
char.bold = bold
|
||||||
|
char.italics = italics
|
||||||
|
char.underscore = underscore
|
||||||
|
char.reverse = reverse
|
||||||
|
return char
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def session_manager(poller: Poller, tmp_path: Path, sample_terminal_app: App) -> SessionManager:
|
def session_manager(poller: Poller, tmp_path: Path, sample_terminal_app: App) -> SessionManager:
|
||||||
"""Create a SessionManager instance."""
|
"""Create a SessionManager instance."""
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class TestLocalServerHelpers:
|
|||||||
request.query = {"route_key": "rk"}
|
request.query = {"route_key": "rk"}
|
||||||
|
|
||||||
screen_buffer = screen_buffer_factory(["hello", ""])
|
screen_buffer = screen_buffer_factory(["hello", ""])
|
||||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
|
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
|
||||||
@@ -203,7 +203,7 @@ class TestLocalServerHelpers:
|
|||||||
request.query = {"route_key": "known"}
|
request.query = {"route_key": "known"}
|
||||||
|
|
||||||
screen_buffer = screen_buffer_factory(["world", ""])
|
screen_buffer = screen_buffer_factory(["world", ""])
|
||||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||||
|
|
||||||
# Pretend app exists for slug "known"
|
# Pretend app exists for slug "known"
|
||||||
server.session_manager.apps_by_slug["known"] = App(
|
server.session_manager.apps_by_slug["known"] = App(
|
||||||
@@ -639,7 +639,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
async def test_handle_screenshot_uses_cached_when_no_changes(
|
async def test_handle_screenshot_uses_cached_when_no_changes(
|
||||||
self, server_with_no_apps, monkeypatch, mock_request, mock_session
|
self, server_with_no_apps, monkeypatch, mock_request, mock_session
|
||||||
):
|
):
|
||||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 24, [], False))
|
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], False))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
server_with_no_apps.session_manager,
|
server_with_no_apps.session_manager,
|
||||||
"get_session_by_route_key",
|
"get_session_by_route_key",
|
||||||
@@ -655,18 +655,17 @@ class TestLocalServerMoreCoverage:
|
|||||||
|
|
||||||
resp = await server_with_no_apps._handle_screenshot(request)
|
resp = await server_with_no_apps._handle_screenshot(request)
|
||||||
assert resp.text == "<svg></svg>"
|
assert resp.text == "<svg></svg>"
|
||||||
mock_session.get_screen_state.assert_not_awaited()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_screenshot_uses_screen_state(
|
async def test_handle_screenshot_uses_screen_state(
|
||||||
self, server_with_no_apps, monkeypatch, screen_buffer_factory, mock_request, mock_session
|
self, server_with_no_apps, monkeypatch, screen_buffer_factory, mock_request, mock_session
|
||||||
):
|
):
|
||||||
"""Test that screenshot uses get_screen_state for rendering."""
|
"""Test that screenshot uses get_screen_snapshot for rendering."""
|
||||||
request = mock_request
|
request = mock_request
|
||||||
request.query = {"route_key": "rk"}
|
request.query = {"route_key": "rk"}
|
||||||
|
|
||||||
screen_buffer = screen_buffer_factory(["line1", "line2"])
|
screen_buffer = screen_buffer_factory(["line1", "line2"])
|
||||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
server_with_no_apps.session_manager,
|
server_with_no_apps.session_manager,
|
||||||
"get_session_by_route_key",
|
"get_session_by_route_key",
|
||||||
@@ -678,7 +677,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
resp = await server_with_no_apps._handle_screenshot(request)
|
resp = await server_with_no_apps._handle_screenshot(request)
|
||||||
assert resp.content_type == "image/svg+xml"
|
assert resp.content_type == "image/svg+xml"
|
||||||
assert "<svg" in resp.text
|
assert "<svg" in resp.text
|
||||||
mock_session.get_screen_state.assert_awaited_once()
|
mock_session.get_screen_snapshot.assert_awaited_once()
|
||||||
|
|
||||||
def test_notify_activity_pushes_to_subscribers(self, server_with_no_apps):
|
def test_notify_activity_pushes_to_subscribers(self, server_with_no_apps):
|
||||||
"""Test that activity notifications are pushed to SSE subscribers."""
|
"""Test that activity notifications are pushed to SSE subscribers."""
|
||||||
|
|||||||
+216
-329
@@ -9,6 +9,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from webterm.terminal_session import (
|
||||||
|
REPLAY_BUFFER_SIZE,
|
||||||
|
TerminalSession,
|
||||||
|
)
|
||||||
|
|
||||||
# Skip tests on Windows
|
# Skip tests on Windows
|
||||||
pytestmark = pytest.mark.skipif(
|
pytestmark = pytest.mark.skipif(
|
||||||
platform.system() == "Windows",
|
platform.system() == "Windows",
|
||||||
@@ -16,46 +21,38 @@ pytestmark = pytest.mark.skipif(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def terminal_session(mock_poller):
|
||||||
|
"""Create a TerminalSession for testing."""
|
||||||
|
return TerminalSession(mock_poller, "test-session", "bash")
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSession:
|
class TestTerminalSession:
|
||||||
"""Tests for TerminalSession class."""
|
"""Tests for TerminalSession class."""
|
||||||
|
|
||||||
def test_import(self):
|
def test_import(self):
|
||||||
"""Test that module can be imported."""
|
"""Test that module can be imported."""
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
assert TerminalSession is not None
|
assert TerminalSession is not None
|
||||||
|
|
||||||
def test_replay_buffer_size(self):
|
def test_replay_buffer_size(self):
|
||||||
"""Test replay buffer size constant."""
|
"""Test replay buffer size constant."""
|
||||||
from webterm.terminal_session import REPLAY_BUFFER_SIZE
|
assert REPLAY_BUFFER_SIZE == 256 * 1024
|
||||||
|
|
||||||
assert REPLAY_BUFFER_SIZE == 256 * 1024 # 64KB
|
def test_init(self, terminal_session):
|
||||||
|
|
||||||
def test_init(self):
|
|
||||||
"""Test TerminalSession initialization."""
|
"""Test TerminalSession initialization."""
|
||||||
from webterm.terminal_session import TerminalSession
|
assert terminal_session.session_id == "test-session"
|
||||||
|
assert terminal_session.command == "bash"
|
||||||
|
assert terminal_session.master_fd is None
|
||||||
|
assert terminal_session.pid is None
|
||||||
|
assert terminal_session._task is None
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
def test_init_default_shell(self, mock_poller):
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
assert session.session_id == "test-session"
|
|
||||||
assert session.command == "bash"
|
|
||||||
assert session.master_fd is None
|
|
||||||
assert session.pid is None
|
|
||||||
assert session._task is None
|
|
||||||
|
|
||||||
def test_init_default_shell(self):
|
|
||||||
"""Test that default shell is used when command is empty."""
|
"""Test that default shell is used when command is empty."""
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
|
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
|
||||||
session = TerminalSession(mock_poller, "test-session", "")
|
session = TerminalSession(mock_poller, "test-session", "")
|
||||||
assert session.command == "/bin/zsh"
|
assert session.command == "/bin/zsh"
|
||||||
|
|
||||||
def test_package_version_fallback(self):
|
def test_package_version_fallback(self):
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("webterm.terminal_session.version", side_effect=RuntimeError()),
|
patch("webterm.terminal_session.version", side_effect=RuntimeError()),
|
||||||
patch("webterm.terminal_session.PackageNotFoundError", RuntimeError),
|
patch("webterm.terminal_session.PackageNotFoundError", RuntimeError),
|
||||||
@@ -63,188 +60,110 @@ class TestTerminalSession:
|
|||||||
assert TerminalSession._package_version() == "0.0.0"
|
assert TerminalSession._package_version() == "0.0.0"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_replay_buffer_add(self):
|
async def test_replay_buffer_add(self, terminal_session):
|
||||||
"""Test adding data to replay buffer."""
|
"""Test adding data to replay buffer."""
|
||||||
from webterm.terminal_session import TerminalSession
|
await terminal_session._add_to_replay_buffer(b"test data")
|
||||||
|
assert terminal_session._replay_buffer_size == 9
|
||||||
mock_poller = MagicMock()
|
assert await terminal_session.get_replay_buffer() == b"test data"
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
await session._add_to_replay_buffer(b"test data")
|
|
||||||
assert session._replay_buffer_size == 9
|
|
||||||
assert await session.get_replay_buffer() == b"test data"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_replay_buffer_multiple_adds(self):
|
async def test_replay_buffer_multiple_adds(self, terminal_session):
|
||||||
"""Test adding multiple chunks to replay buffer."""
|
"""Test adding multiple chunks to replay buffer."""
|
||||||
from webterm.terminal_session import TerminalSession
|
await terminal_session._add_to_replay_buffer(b"chunk1")
|
||||||
|
await terminal_session._add_to_replay_buffer(b"chunk2")
|
||||||
mock_poller = MagicMock()
|
assert await terminal_session.get_replay_buffer() == b"chunk1chunk2"
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
await session._add_to_replay_buffer(b"chunk1")
|
|
||||||
await session._add_to_replay_buffer(b"chunk2")
|
|
||||||
assert await session.get_replay_buffer() == b"chunk1chunk2"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_replay_buffer_overflow(self):
|
async def test_replay_buffer_overflow(self, terminal_session):
|
||||||
"""Test that replay buffer trims old data when exceeding limit."""
|
"""Test that replay buffer trims old data when exceeding limit."""
|
||||||
from webterm.terminal_session import (
|
|
||||||
REPLAY_BUFFER_SIZE,
|
|
||||||
TerminalSession,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
# Add more data than buffer size
|
# Add more data than buffer size
|
||||||
chunk_size = 1024
|
chunk_size = 1024
|
||||||
for _i in range(100): # 100KB total
|
for _i in range(100): # 100KB total
|
||||||
await session._add_to_replay_buffer(b"x" * chunk_size)
|
await terminal_session._add_to_replay_buffer(b"x" * chunk_size)
|
||||||
|
|
||||||
# Buffer should be trimmed
|
# Buffer should be trimmed
|
||||||
assert session._replay_buffer_size <= REPLAY_BUFFER_SIZE + chunk_size
|
assert terminal_session._replay_buffer_size <= REPLAY_BUFFER_SIZE + chunk_size
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_screen_state_updates_with_data(self):
|
async def test_screen_state_updates_with_data(self, terminal_session):
|
||||||
"""Test that pyte screen updates when data is received."""
|
"""Test that pyte screen updates when data is received."""
|
||||||
from webterm.terminal_session import TerminalSession
|
await terminal_session._update_screen(b"Hello World\r\n")
|
||||||
|
lines = await terminal_session.get_screen_lines()
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
# Feed some terminal data
|
|
||||||
await session._update_screen(b"Hello World\r\n")
|
|
||||||
lines = await session.get_screen_lines()
|
|
||||||
|
|
||||||
# First line should contain the text
|
|
||||||
assert "Hello World" in lines[0]
|
assert "Hello World" in lines[0]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_screen_handles_cursor_positioning(self):
|
async def test_screen_handles_cursor_positioning(self, terminal_session):
|
||||||
"""Test that pyte screen correctly handles cursor positioning (tmux-style)."""
|
"""Test that pyte screen correctly handles cursor positioning (tmux-style)."""
|
||||||
from webterm.terminal_session import TerminalSession
|
await terminal_session._update_screen(b"Line 1\r\nLine 2\r\nLine 3\r\n")
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
# Feed content then reposition cursor and overwrite
|
|
||||||
await session._update_screen(b"Line 1\r\nLine 2\r\nLine 3\r\n")
|
|
||||||
# Move cursor to line 2, column 1 and clear line, then write new content
|
# Move cursor to line 2, column 1 and clear line, then write new content
|
||||||
await session._update_screen(b"\x1b[2;1H\x1b[KUpdated Line 2")
|
await terminal_session._update_screen(b"\x1b[2;1H\x1b[KUpdated Line 2")
|
||||||
|
|
||||||
lines = await session.get_screen_lines()
|
lines = await terminal_session.get_screen_lines()
|
||||||
|
|
||||||
assert lines[0] == "Line 1"
|
assert lines[0] == "Line 1"
|
||||||
assert lines[1] == "Updated Line 2"
|
assert lines[1] == "Updated Line 2"
|
||||||
assert lines[2] == "Line 3"
|
assert lines[2] == "Line 3"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_state_returns_dirty_flag(self):
|
async def test_get_screen_state_returns_dirty_flag(self, terminal_session):
|
||||||
"""Test that get_screen_state returns has_changes flag based on pyte dirty tracking."""
|
"""Test that get_screen_state returns has_changes flag based on pyte dirty tracking."""
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
# After creation, all rows are dirty (initialized)
|
# After creation, all rows are dirty (initialized)
|
||||||
_w, _h, _buf, has_changes = await session.get_screen_state()
|
_w, _h, _buf, has_changes = await terminal_session.get_screen_state()
|
||||||
assert has_changes is True # Initial state marks all rows dirty
|
assert has_changes is True
|
||||||
|
|
||||||
# After getting state, dirty set is cleared
|
# After getting state, dirty set is cleared
|
||||||
# Without new data, has_changes should be False
|
_, _, _, has_changes = await terminal_session.get_screen_state()
|
||||||
_, _, _, has_changes = await session.get_screen_state()
|
assert has_changes is False
|
||||||
assert has_changes is False # No changes since last call
|
|
||||||
|
|
||||||
# Feed new data
|
# Feed new data
|
||||||
await session._update_screen(b"New content\r\n")
|
await terminal_session._update_screen(b"New content\r\n")
|
||||||
_, _, _, has_changes = await session.get_screen_state()
|
_, _, _, has_changes = await terminal_session.get_screen_state()
|
||||||
assert has_changes is True # Screen was updated
|
assert has_changes is True
|
||||||
|
|
||||||
# Check again without new data
|
# Check again without new data
|
||||||
_, _, _, has_changes = await session.get_screen_state()
|
_, _, _, has_changes = await terminal_session.get_screen_state()
|
||||||
assert has_changes is False # No changes
|
assert has_changes is False
|
||||||
|
|
||||||
def test_update_connector(self):
|
def test_update_connector(self, terminal_session):
|
||||||
"""Test updating connector."""
|
"""Test updating connector."""
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
mock_connector = MagicMock()
|
mock_connector = MagicMock()
|
||||||
session.update_connector(mock_connector)
|
terminal_session.update_connector(mock_connector)
|
||||||
assert session._connector == mock_connector
|
assert terminal_session._connector == mock_connector
|
||||||
|
|
||||||
def test_is_running_not_started(self):
|
def test_is_running_not_started(self, terminal_session):
|
||||||
"""Test is_running when session not started."""
|
"""Test is_running when session not started."""
|
||||||
from webterm.terminal_session import TerminalSession
|
assert terminal_session.is_running() is False
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
assert session.is_running() is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_no_fd(self):
|
async def test_send_bytes_no_fd(self, terminal_session):
|
||||||
"""Test send_bytes returns False when no master_fd."""
|
"""Test send_bytes returns False when no master_fd."""
|
||||||
from webterm.terminal_session import TerminalSession
|
result = await terminal_session.send_bytes(b"test")
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
result = await session.send_bytes(b"test")
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_meta(self):
|
async def test_send_meta(self, terminal_session):
|
||||||
"""Test send_meta returns True."""
|
"""Test send_meta returns True."""
|
||||||
from webterm.terminal_session import TerminalSession
|
result = await terminal_session.send_meta({})
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
result = await session.send_meta({})
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_no_pid(self):
|
async def test_close_no_pid(self, terminal_session):
|
||||||
"""Test close when no pid."""
|
"""Test close when no pid."""
|
||||||
from webterm.terminal_session import TerminalSession
|
await terminal_session.close() # Should not raise
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wait_no_task(self):
|
async def test_wait_no_task(self, terminal_session):
|
||||||
"""Test wait when no task."""
|
"""Test wait when no task."""
|
||||||
from webterm.terminal_session import TerminalSession
|
await terminal_session.wait() # Should not raise
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
def test_repr(self, terminal_session):
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
await session.wait()
|
|
||||||
|
|
||||||
def test_repr(self):
|
|
||||||
"""Test repr output."""
|
"""Test repr output."""
|
||||||
from webterm.terminal_session import TerminalSession
|
repr_str = repr(terminal_session)
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
|
||||||
|
|
||||||
repr_str = repr(session)
|
|
||||||
assert "test-session" in repr_str
|
assert "test-session" in repr_str
|
||||||
assert "bash" in repr_str
|
assert "bash" in repr_str
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_uses_shlex_split_and_execvp_with_args(self):
|
async def test_open_uses_shlex_split_and_execvp_with_args(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
|
||||||
command = 'echo "hello world"'
|
command = 'echo "hello world"'
|
||||||
session = TerminalSession(mock_poller, "test-session", command)
|
session = TerminalSession(mock_poller, "test-session", command)
|
||||||
|
|
||||||
@@ -270,11 +189,8 @@ class TestTerminalSession:
|
|||||||
mock_exit.assert_called_once_with(1)
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_parent_branch_sets_fd_and_pid(self):
|
async def test_open_parent_branch_sets_fd_and_pid(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
|
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
|
||||||
@@ -287,11 +203,8 @@ class TestTerminalSession:
|
|||||||
set_size.assert_called_once_with(80, 24)
|
set_size.assert_called_once_with(80, 24)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_bad_command_exits(self):
|
async def test_open_bad_command_exits(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bad")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bad")
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
|
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
|
||||||
@@ -306,150 +219,160 @@ class TestTerminalSession:
|
|||||||
mock_exit.assert_called_once_with(1)
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_lines_strips(self):
|
async def test_get_screen_lines_strips(self, terminal_session, dummy_lock):
|
||||||
from webterm.terminal_session import TerminalSession
|
terminal_session._screen = MagicMock()
|
||||||
|
terminal_session._screen.display = ["line ", "next"]
|
||||||
|
terminal_session._screen_lock = dummy_lock
|
||||||
|
|
||||||
poller = MagicMock()
|
lines = await terminal_session.get_screen_lines()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session._screen = MagicMock()
|
|
||||||
session._screen.display = ["line ", "next"]
|
|
||||||
|
|
||||||
class DummyLock:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
session._screen_lock = DummyLock()
|
|
||||||
|
|
||||||
lines = await session.get_screen_lines()
|
|
||||||
assert lines == ["line", "next"]
|
assert lines == ["line", "next"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_state_no_changes(self):
|
async def test_get_screen_state_no_changes(
|
||||||
from webterm.terminal_session import TerminalSession
|
self, terminal_session, dummy_lock, mock_screen_char
|
||||||
|
):
|
||||||
|
terminal_session._screen = MagicMock()
|
||||||
|
terminal_session._screen.columns = 1
|
||||||
|
terminal_session._screen.lines = 1
|
||||||
|
terminal_session._screen.dirty = set()
|
||||||
|
terminal_session._screen.buffer = [[mock_screen_char()]]
|
||||||
|
terminal_session._sync_pyte_to_pty = AsyncMock()
|
||||||
|
terminal_session._screen_lock = dummy_lock
|
||||||
|
|
||||||
poller = MagicMock()
|
width, height, _buffer, changed = await terminal_session.get_screen_state()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session._screen = MagicMock()
|
|
||||||
session._screen.columns = 1
|
|
||||||
session._screen.lines = 1
|
|
||||||
session._screen.dirty = set()
|
|
||||||
session._screen.buffer = [
|
|
||||||
[
|
|
||||||
MagicMock(
|
|
||||||
data=" ", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
session._sync_pyte_to_pty = AsyncMock()
|
|
||||||
|
|
||||||
class DummyLock:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
session._screen_lock = DummyLock()
|
|
||||||
|
|
||||||
width, height, _buffer, changed = await session.get_screen_state()
|
|
||||||
assert width == 1
|
assert width == 1
|
||||||
assert height == 1
|
assert height == 1
|
||||||
assert changed is False
|
assert changed is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_state_clears_dirty(self):
|
async def test_get_screen_state_clears_dirty(
|
||||||
from webterm.terminal_session import TerminalSession
|
self, terminal_session, dummy_lock, mock_screen_char
|
||||||
|
):
|
||||||
|
terminal_session._screen = MagicMock()
|
||||||
|
terminal_session._screen.columns = 2
|
||||||
|
terminal_session._screen.lines = 1
|
||||||
|
terminal_session._screen.dirty = {1}
|
||||||
|
terminal_session._screen.buffer = [[mock_screen_char("x"), mock_screen_char("y")]]
|
||||||
|
terminal_session._sync_pyte_to_pty = AsyncMock()
|
||||||
|
terminal_session._screen_lock = dummy_lock
|
||||||
|
|
||||||
poller = MagicMock()
|
width, height, _buffer, changed = await terminal_session.get_screen_state()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session._screen = MagicMock()
|
|
||||||
session._screen.columns = 2
|
|
||||||
session._screen.lines = 1
|
|
||||||
session._screen.dirty = {1}
|
|
||||||
session._screen.buffer = [
|
|
||||||
[
|
|
||||||
MagicMock(
|
|
||||||
data="x", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False
|
|
||||||
),
|
|
||||||
MagicMock(
|
|
||||||
data="y", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False
|
|
||||||
),
|
|
||||||
]
|
|
||||||
]
|
|
||||||
session._sync_pyte_to_pty = AsyncMock()
|
|
||||||
|
|
||||||
class DummyLock:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
session._screen_lock = DummyLock()
|
|
||||||
|
|
||||||
width, height, _buffer, changed = await session.get_screen_state()
|
|
||||||
assert width == 2
|
assert width == 2
|
||||||
assert height == 1
|
assert height == 1
|
||||||
assert changed is True
|
assert changed is True
|
||||||
assert session._screen.dirty == set()
|
assert terminal_session._screen.dirty == set()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_has_changes_reads_dirty(self):
|
async def test_get_screen_snapshot_does_not_mutate_state(
|
||||||
from webterm.terminal_session import TerminalSession
|
self, terminal_session, dummy_lock, mock_screen_char
|
||||||
|
):
|
||||||
|
"""Test that get_screen_snapshot doesn't call _sync_pyte_to_pty or clear dirty."""
|
||||||
|
terminal_session._screen = MagicMock()
|
||||||
|
terminal_session._screen.columns = 2
|
||||||
|
terminal_session._screen.lines = 1
|
||||||
|
terminal_session._screen.dirty = {0}
|
||||||
|
terminal_session._screen.buffer = [[mock_screen_char("a"), mock_screen_char("b")]]
|
||||||
|
terminal_session._sync_pyte_to_pty = AsyncMock()
|
||||||
|
terminal_session._screen_lock = dummy_lock
|
||||||
|
terminal_session._change_counter = 1
|
||||||
|
terminal_session._last_snapshot_counter = 0
|
||||||
|
|
||||||
poller = MagicMock()
|
width, height, buffer, has_changes = await terminal_session.get_screen_snapshot()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session._screen = MagicMock()
|
|
||||||
session._screen.dirty = {1}
|
|
||||||
|
|
||||||
class DummyLock:
|
# Verify dimensions and data returned correctly
|
||||||
async def __aenter__(self):
|
assert width == 2
|
||||||
return None
|
assert height == 1
|
||||||
|
assert has_changes is True
|
||||||
|
assert buffer[0][0]["data"] == "a"
|
||||||
|
assert buffer[0][1]["data"] == "b"
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
# Verify no mutation: _sync_pyte_to_pty not called, dirty not cleared
|
||||||
return False
|
terminal_session._sync_pyte_to_pty.assert_not_awaited()
|
||||||
|
assert terminal_session._screen.dirty == {0} # NOT cleared
|
||||||
|
|
||||||
session._screen_lock = DummyLock()
|
# Snapshot counter should be updated for change tracking
|
||||||
session._sync_pyte_to_pty = AsyncMock()
|
assert terminal_session._last_snapshot_counter == 1
|
||||||
|
|
||||||
changed = await session.get_screen_has_changes()
|
|
||||||
assert changed is True
|
|
||||||
session._screen.dirty = set()
|
|
||||||
changed = await session.get_screen_has_changes()
|
|
||||||
assert changed is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_handles_closed_fd(self):
|
async def test_get_screen_snapshot_tracks_changes_correctly(self, terminal_session, dummy_lock):
|
||||||
from webterm.terminal_session import TerminalSession
|
"""Test that repeated snapshots correctly track changes."""
|
||||||
|
terminal_session._screen_lock = dummy_lock
|
||||||
|
terminal_session._change_counter = 5
|
||||||
|
terminal_session._last_snapshot_counter = 5
|
||||||
|
|
||||||
poller = MagicMock()
|
# No changes since last snapshot
|
||||||
poller.write = AsyncMock(side_effect=KeyError)
|
_, _, _, has_changes = await terminal_session.get_screen_snapshot()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
assert has_changes is False
|
||||||
|
|
||||||
|
# Simulate new screen data
|
||||||
|
terminal_session._change_counter = 6
|
||||||
|
_, _, _, has_changes = await terminal_session.get_screen_snapshot()
|
||||||
|
assert has_changes is True
|
||||||
|
assert terminal_session._last_snapshot_counter == 6
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_screen_increments_change_counter(self, terminal_session):
|
||||||
|
"""Test that _update_screen increments change counter when screen changes."""
|
||||||
|
initial_counter = terminal_session._change_counter
|
||||||
|
|
||||||
|
# Feed data that will mark screen as dirty
|
||||||
|
await terminal_session._update_screen(b"Hello\r\n")
|
||||||
|
|
||||||
|
assert terminal_session._change_counter > initial_counter
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_terminal_size_increments_change_counter(self, terminal_session, mock_poller):
|
||||||
|
"""Test that set_terminal_size increments change counter."""
|
||||||
|
terminal_session.master_fd = 10
|
||||||
|
initial_counter = terminal_session._change_counter
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with patch.object(loop, "run_in_executor", new=AsyncMock()):
|
||||||
|
await terminal_session.set_terminal_size(100, 50)
|
||||||
|
|
||||||
|
assert terminal_session._change_counter == initial_counter + 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_screen_has_changes_uses_change_counter(self, terminal_session, dummy_lock):
|
||||||
|
"""Test that get_screen_has_changes uses the change counter."""
|
||||||
|
terminal_session._screen_lock = dummy_lock
|
||||||
|
|
||||||
|
# Initially no changes
|
||||||
|
terminal_session._change_counter = 0
|
||||||
|
terminal_session._last_snapshot_counter = 0
|
||||||
|
assert await terminal_session.get_screen_has_changes() is False
|
||||||
|
|
||||||
|
# After screen update increments counter
|
||||||
|
terminal_session._change_counter = 1
|
||||||
|
assert await terminal_session.get_screen_has_changes() is True
|
||||||
|
|
||||||
|
# After snapshot resets detection
|
||||||
|
terminal_session._last_snapshot_counter = 1
|
||||||
|
assert await terminal_session.get_screen_has_changes() is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bytes_handles_closed_fd(self, mock_poller):
|
||||||
|
mock_poller.write = AsyncMock(side_effect=KeyError)
|
||||||
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
|
|
||||||
ok = await session.send_bytes(b"test")
|
ok = await session.send_bytes(b"test")
|
||||||
assert ok is False
|
assert ok is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_reads_from_poller_and_closes(self):
|
async def test_run_reads_from_poller_and_closes(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||||
await queue.put(b"hello")
|
await queue.put(b"hello")
|
||||||
await queue.put(None)
|
await queue.put(None)
|
||||||
|
|
||||||
poller = MagicMock()
|
mock_poller.add_file = MagicMock(return_value=queue)
|
||||||
poller.add_file = MagicMock(return_value=queue)
|
mock_poller.remove_file = MagicMock()
|
||||||
poller.remove_file = MagicMock()
|
|
||||||
|
|
||||||
connector = MagicMock()
|
connector = MagicMock()
|
||||||
connector.on_data = AsyncMock()
|
connector.on_data = AsyncMock()
|
||||||
connector.on_close = AsyncMock()
|
connector.on_close = AsyncMock()
|
||||||
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._connector = connector
|
session._connector = connector
|
||||||
|
|
||||||
@@ -458,15 +381,12 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
connector.on_data.assert_awaited_once_with(b"hello")
|
connector.on_data.assert_awaited_once_with(b"hello")
|
||||||
connector.on_close.assert_awaited_once()
|
connector.on_close.assert_awaited_once()
|
||||||
poller.remove_file.assert_called_once_with(10)
|
mock_poller.remove_file.assert_called_once_with(10)
|
||||||
mock_close.assert_called_once_with(10)
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_updates_connector_when_already_running(self):
|
async def test_start_updates_connector_when_already_running(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
|
|
||||||
existing = asyncio.create_task(asyncio.sleep(0))
|
existing = asyncio.create_task(asyncio.sleep(0))
|
||||||
@@ -480,24 +400,18 @@ class TestTerminalSession:
|
|||||||
await existing
|
await existing
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_writes_via_poller(self):
|
async def test_send_bytes_writes_via_poller(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
mock_poller.write = AsyncMock()
|
||||||
|
|
||||||
poller = MagicMock()
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
poller.write = AsyncMock()
|
|
||||||
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
|
|
||||||
assert await session.send_bytes(b"x") is True
|
assert await session.send_bytes(b"x") is True
|
||||||
poller.write.assert_awaited_once_with(10, b"x")
|
mock_poller.write.assert_awaited_once_with(10, b"x")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_set_terminal_size_oserror_closes_fd_and_clears_master_fd(self):
|
async def test_open_set_terminal_size_oserror_closes_fd_and_clears_master_fd(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
|
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
|
||||||
@@ -511,11 +425,8 @@ class TestTerminalSession:
|
|||||||
assert session.master_fd is None
|
assert session.master_fd is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_terminal_size_uses_executor(self):
|
async def test_set_terminal_size_uses_executor(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -524,11 +435,8 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
run_in_executor.assert_awaited_once_with(None, session._set_terminal_size, 80, 24)
|
run_in_executor.assert_awaited_once_with(None, session._set_terminal_size, 80, 24)
|
||||||
|
|
||||||
def test__set_terminal_size_calls_ioctl(self):
|
def test__set_terminal_size_calls_ioctl(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
|
|
||||||
with patch("webterm.terminal_session.fcntl.ioctl") as mock_ioctl:
|
with patch("webterm.terminal_session.fcntl.ioctl") as mock_ioctl:
|
||||||
@@ -537,11 +445,8 @@ class TestTerminalSession:
|
|||||||
assert mock_ioctl.called
|
assert mock_ioctl.called
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_creates_task_when_not_running(self):
|
async def test_start_creates_task_when_not_running(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
|
|
||||||
session.run = AsyncMock() # type: ignore[method-assign]
|
session.run = AsyncMock() # type: ignore[method-assign]
|
||||||
@@ -555,65 +460,53 @@ class TestTerminalSession:
|
|||||||
session.run.assert_awaited_once()
|
session.run.assert_awaited_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_without_connector_still_closes(self):
|
async def test_run_without_connector_still_closes(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||||
await queue.put(b"hello")
|
await queue.put(b"hello")
|
||||||
await queue.put(None)
|
await queue.put(None)
|
||||||
|
|
||||||
poller = MagicMock()
|
mock_poller.add_file = MagicMock(return_value=queue)
|
||||||
poller.add_file = MagicMock(return_value=queue)
|
mock_poller.remove_file = MagicMock()
|
||||||
poller.remove_file = MagicMock()
|
|
||||||
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._connector = None
|
session._connector = None
|
||||||
|
|
||||||
with patch("webterm.terminal_session.os.close") as mock_close:
|
with patch("webterm.terminal_session.os.close") as mock_close:
|
||||||
await session.run()
|
await session.run()
|
||||||
|
|
||||||
poller.remove_file.assert_called_once_with(10)
|
mock_poller.remove_file.assert_called_once_with(10)
|
||||||
mock_close.assert_called_once_with(10)
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_oserror_still_closes(self):
|
async def test_run_oserror_still_closes(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
queue = MagicMock()
|
queue = MagicMock()
|
||||||
queue.get = AsyncMock(side_effect=OSError("boom"))
|
queue.get = AsyncMock(side_effect=OSError("boom"))
|
||||||
|
|
||||||
poller = MagicMock()
|
mock_poller.add_file = MagicMock(return_value=queue)
|
||||||
poller.add_file = MagicMock(return_value=queue)
|
mock_poller.remove_file = MagicMock()
|
||||||
poller.remove_file = MagicMock()
|
|
||||||
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._connector = None
|
session._connector = None
|
||||||
|
|
||||||
with patch("webterm.terminal_session.os.close") as mock_close:
|
with patch("webterm.terminal_session.os.close") as mock_close:
|
||||||
await session.run()
|
await session.run()
|
||||||
|
|
||||||
poller.remove_file.assert_called_once_with(10)
|
mock_poller.remove_file.assert_called_once_with(10)
|
||||||
mock_close.assert_called_once_with(10)
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_process_lookup_error_is_ignored(self):
|
async def test_close_process_lookup_error_is_ignored(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.pid = 123
|
session.pid = 123
|
||||||
|
|
||||||
with patch("webterm.terminal_session.os.kill", side_effect=ProcessLookupError()):
|
with patch("webterm.terminal_session.os.kill", side_effect=ProcessLookupError()):
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_logs_warning_on_unexpected_exception(self):
|
async def test_close_logs_warning_on_unexpected_exception(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.pid = 123
|
session.pid = 123
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -625,11 +518,8 @@ class TestTerminalSession:
|
|||||||
assert warn.called
|
assert warn.called
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wait_suppresses_cancelled_error(self):
|
async def test_wait_suppresses_cancelled_error(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
|
|
||||||
task = asyncio.create_task(asyncio.sleep(10))
|
task = asyncio.create_task(asyncio.sleep(10))
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -637,11 +527,8 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
await session.wait()
|
await session.wait()
|
||||||
|
|
||||||
def test_is_running_false_when_kill_fails(self):
|
def test_is_running_false_when_kill_fails(self, mock_poller):
|
||||||
from webterm.terminal_session import TerminalSession
|
session = TerminalSession(mock_poller, "sid", "bash")
|
||||||
|
|
||||||
poller = MagicMock()
|
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
|
||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._task = MagicMock()
|
session._task = MagicMock()
|
||||||
session.pid = 123
|
session.pid = 123
|
||||||
|
|||||||
Reference in New Issue
Block a user