Bump minor version and update ghostty-web

This commit is contained in:
GitHub Copilot
2026-01-28 16:13:08 +00:00
parent 69f0e2748f
commit b4d7f2e98a
63 changed files with 581 additions and 1432 deletions
+30
View File
@@ -0,0 +1,30 @@
# Keep the build context minimal for pip installs
.git
.github
.idea
.vscode
__pycache__
*.py[cod]
*.pyo
*.pyd
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
htmlcov
build
dist
*.egg-info
node_modules
bun.lock
bun.lockb
package-lock.json
package.json
tsconfig.json
bunfig.toml
tests
docs
examples
Dockerfile
README.md
LICENSE
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- runner: ubuntu-latest
platform: linux/amd64
suffix: amd64
- runner: ubuntu-24.04-arm
- runner: ubuntu-24.04-arm64
platform: linux/arm64
suffix: arm64
+9 -2
View File
@@ -7,6 +7,7 @@
__pycache__/
*.py[cod]
*$py.class
*.pyi
# C extensions
*.so
@@ -122,14 +123,20 @@ dmypy.json
# pyright
.pyright/
# cache / build artifacts
.cache/
.coverage
htmlcov/
# pdm
.pdm.toml
.pdm-python
.pdm-build/
# textual-webterm specific
textual.log
# webterm specific
webterm.log
# Node.js / Bun (for development only)
node_modules/
bun.lockb
package-lock.json
+4 -6
View File
@@ -1,7 +1,7 @@
# Minimal image for serving a web terminal with Docker watch mode
#
# Build: docker build -t textual-webterm .
# Run: docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 textual-webterm --docker-watch
# Build: docker build -t webterm .
# Run: docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 webterm --docker-watch
#
FROM python:3.12-slim AS builder
@@ -14,8 +14,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /build
COPY pyproject.toml poetry.lock* ./
COPY src/ ./src/
COPY Makefile ./
# Install the package
RUN pip install --no-cache-dir .
@@ -29,7 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin/textual-webterm /usr/local/bin/textual-webterm
COPY --from=builder /usr/local/bin/webterm /usr/local/bin/webterm
# Create non-root user (optional, but may need root for Docker socket access)
# RUN useradd -m webterm
@@ -37,5 +35,5 @@ COPY --from=builder /usr/local/bin/textual-webterm /usr/local/bin/textual-webter
EXPOSE 8080
ENTRYPOINT ["textual-webterm"]
ENTRYPOINT ["webterm"]
CMD ["--host", "0.0.0.0", "--port", "8080", "--docker-watch"]
+1 -1
View File
@@ -44,4 +44,4 @@ Option 2 would be cleanest as it separates parsing from rendering.
- Browser: All
---
*Filed from textual-webterm project where we encountered this while implementing theme support*
*Filed from webterm project where we encountered this while implementing theme support*
+2 -2
View File
@@ -4,7 +4,7 @@ PYTHON ?= python3
PIP ?= $(PYTHON) -m pip
# Static assets
STATIC_JS_DIR = src/textual_webterm/static/js
STATIC_JS_DIR = src/webterm/static/js
TERMINAL_JS = $(STATIC_JS_DIR)/terminal.js
TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
@@ -60,7 +60,7 @@ test:
pytest
coverage:
pytest --cov=src/textual_webterm --cov-report=term-missing
pytest --cov=src/webterm --cov-report=term-missing
check: lint coverage
+33 -58
View File
@@ -1,34 +1,31 @@
# textual-webterm
# webterm
![Icon](docs/icon-256.png)
Serve terminal sessions and Textual apps over the web with a simple CLI command.
Serve terminal sessions over the web with a simple CLI command.
This is heavily based on [textual-web](https://github.com/Textualize/textual-web), but specifically focused on serving a persistent terminal session in a way that you can host behind a reverse proxy (and some form of authentication).
> **Credit and Inspiration:** This project was originally based on the genius [web](https://github.com/Textualize/web) package, which uses `xterm.js`. It has been rewritten to use a [ghostty-web](https://github.com/coder/ghostty-web)'s WebAssembly-based terminal emulator, which provides better performance and native theme support.
Built on a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web) (replacing the original xterm.js dependency), this package provides an easy way to expose terminal sessions via HTTP/WebSocket with automatic reconnection support.
It is, for the moment, temporarily based on a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web), because the current version has bugs and feature gaps that I needed to fill.
> **Note:** This project originally used [textual-web](https://github.com/Textualize/textual-web) with xterm.js. It has been rewritten to use [ghostty-web](https://github.com/coder/ghostty-web)'s WebAssembly-based terminal emulator, which provides better performance and native theme support. Textual app serving has been deprecated in favor of direct terminal access.
Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to keep track of several containerized AI coding agents:
Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to keep track of several containerized AI coding agents, since it provides an easy way to expose terminal sessions via HTTP/WebSocket with automatic reconnection support:
![Screenshot](docs/screenshot.png)
## Features
- 🖥️ **Web-based terminal** - Access your terminal from any browser
- 📱 **Mobile support** - Works on iOS Safari and Android with on-screen keyboard
- 🐍 **Textual app support** - Serve Textual apps directly from Python modules
- 🔄 **Session reconnection** - Refresh the page and reconnect to the same session
- 🎨 **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly
- 🎭 **Customizable themes** - 9 built-in themes (monokai, dracula, nord, etc.)
- 🔤 **Custom fonts** - Configure terminal font family and size
- 📜 **Scrollback history** - Scroll back through terminal output (configurable)
- 📐 **Auto-sizing** - Terminal automatically resizes to fit the browser window
- 📸 **Live screenshots** - Dashboard shows real-time SVG screenshots of terminals
- 📊 **CPU sparklines** - Dashboard displays 30-minute CPU history for Docker containers
- **SSE updates** - Real-time screenshot updates via Server-Sent Events
- 🚀 **Simple CLI** - One command to start serving
- **Web-based terminal** - Access your terminal from any browser
- **Mobile support** - Works on iOS Safari and Android with on-screen keyboard
- **Session reconnection** - Refresh the page and reconnect to the same session
- **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly
- **Customizable themes** - 9 built-in themes (monokai, dracula, nord, etc.)
- **Custom fonts** - Configure terminal font family and size
- **Scrollback history** - Scroll back through terminal output (configurable)
- **Auto-sizing** - Terminal automatically resizes to fit the browser window
- **Live screenshots** - Dashboard shows real-time SVG screenshots of terminals
- **CPU sparklines** - Dashboard displays 30-minute CPU history for Docker containers
- **SSE updates** - Real-time screenshot updates via Server-Sent Events
- **Simple CLI** - One command to start serving
## Non-Features
@@ -37,16 +34,10 @@ Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to
## Installation
Install from PyPI:
Install directly from GitHub:
```bash
pip install textual-webterm
```
Or install directly from GitHub:
```bash
pip install git+https://github.com/rcarmo/textual-webterm.git
pip install git+https://github.com/rcarmo/webterm.git
```
## Quick Start
@@ -56,27 +47,13 @@ pip install git+https://github.com/rcarmo/textual-webterm.git
Serve your default shell:
```bash
textual-webterm
webterm
```
Serve a specific command:
```bash
textual-webterm htop
```
### Serve a Textual App
Serve a Textual app from an installed module:
```bash
textual-webterm --app mypackage.mymodule:MyApp
```
Serve a Textual app from a Python file:
```bash
textual-webterm --app ./calculator.py:CalculatorApp
webterm htop
```
### Options
@@ -84,14 +61,14 @@ textual-webterm --app ./calculator.py:CalculatorApp
Specify host and port:
```bash
textual-webterm --host 0.0.0.0 --port 8080 bash
webterm --host 0.0.0.0 --port 8080 bash
```
Customize theme and font:
```bash
textual-webterm --theme dracula --font-size 18
textual-webterm --theme nord --font-family "JetBrains Mono, monospace"
webterm --theme dracula --font-size 18
webterm --theme nord --font-family "JetBrains Mono, monospace"
```
Available themes: `xterm` (default), `monokai`, `dark`, `light`, `dracula`, `catppuccin`, `nord`, `gruvbox`, `solarized`, `tokyo`.
@@ -111,7 +88,7 @@ You can serve a dashboard with multiple terminal tiles driven by a YAML manifest
Run with:
```bash
textual-webterm --landing-manifest landing.yaml
webterm --landing-manifest landing.yaml
```
### Docker Watch Mode
@@ -119,7 +96,7 @@ textual-webterm --landing-manifest landing.yaml
Watch for Docker containers with the `webterm-command` label and dynamically add/remove terminal sessions:
```bash
textual-webterm --docker-watch
webterm --docker-watch
```
When a container starts with the label, it automatically appears in the dashboard. When it stops, it's removed. Label values:
@@ -159,7 +136,7 @@ services:
Start with:
```bash
textual-webterm --compose-manifest compose.yaml
webterm --compose-manifest compose.yaml
```
In compose mode, the dashboard displays **CPU sparklines** showing 30 minutes of container CPU usage history (requires access to Docker socket at `/var/run/docker.sock`).
@@ -175,17 +152,15 @@ In compose mode, the dashboard displays **CPU sparklines** showing 30 minutes of
## CLI Reference
```
Usage: textual-webterm [OPTIONS] [COMMAND]
Usage: webterm [OPTIONS] [COMMAND]
Serve a terminal or Textual app over HTTP/WebSocket.
Serve a terminal over HTTP/WebSocket.
COMMAND: Shell command to run in terminal (default: $SHELL)
Options:
-H, --host TEXT Host to bind to [default: 0.0.0.0]
-p, --port INTEGER Port to bind to [default: 8080]
-a, --app TEXT Load a Textual app from module:ClassName
Examples: 'mymodule:MyApp' or './app.py:MyApp'
-L, --landing-manifest PATH YAML manifest describing landing page tiles
(slug/name/command).
-C, --compose-manifest PATH Docker compose YAML; services with label
@@ -218,8 +193,8 @@ Options:
### Setup (Makefile-first)
```bash
git clone https://github.com/rcarmo/textual-webterm.git
cd textual-webterm
git clone https://github.com/rcarmo/webterm.git
cd webterm
# Install with dev dependencies via Makefile
make install-dev
@@ -265,13 +240,14 @@ make bundle-watch
### Notes
- WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`.
- Frontend source is in `src/textual_webterm/static/js/terminal.ts`.
- Frontend source is in `src/webterm/static/js/terminal.ts`.
- Screenshots use [pyte](https://github.com/selectel/pyte) for ANSI interpretation and custom SVG rendering.
- CPU stats are read directly from Docker socket using asyncio (no additional dependencies).
## Requirements
- Python 3.9+
- Bun
- Linux or macOS
## License
@@ -282,5 +258,4 @@ MIT License - see [LICENSE](LICENSE) for details.
- [ghostty-web](https://github.com/rcarmo/ghostty-web) - Patched Ghostty terminal for the web (vendored fork with theme support)
- [ghostty-web upstream](https://github.com/coder/ghostty-web) - Original Ghostty terminal for the web
- [Textual](https://github.com/Textualize/textual) - TUI framework for Python (legacy support)
- [pyte](https://github.com/selectel/pyte) - PYTE terminal emulator (used for SVG screenshots)
+3 -3
View File
@@ -3,9 +3,9 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "textual-webterm-frontend",
"name": "webterm-frontend",
"dependencies": {
"ghostty-web": "^0.1.0",
"ghostty-web": "github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de",
},
"devDependencies": {
"typescript": "^5.7.0",
@@ -13,7 +13,7 @@
},
},
"packages": {
"ghostty-web": ["ghostty-web@0.1.1", "", {}, "sha512-uPlk+EDNtA0uS47yxsn9VpRIFC57rm1zoRf1vCZ0Lh8DN5kw+Szyof591G+RFYNBqL1FJxMFGVzVjY8ykzteiw=="],
"ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de", {}, "rcarmo-ghostty-web-2837b81646aa0c00bb5a2881b5a53400346c76de"],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
}
+6 -7
View File
@@ -1,12 +1,12 @@
# Architecture
This document describes the internal architecture of textual-webterm.
This document describes the internal architecture of webterm.
## Overview
textual-webterm is a web-based terminal server that exposes terminal sessions over HTTP and WebSocket. It's designed to run behind a reverse proxy with authentication.
webterm is a web-based terminal server that exposes terminal sessions over HTTP and WebSocket. It's designed to run behind a reverse proxy with authentication.
> **Note:** As of v1.0.0, this project uses [ghostty-web](https://github.com/rcarmo/ghostty-web) (a patched fork with native theme support) instead of xterm.js. Textual app support has been deprecated in favor of direct terminal access.
> **Note:** As of v1.0.0, this project uses [ghostty-web](https://github.com/rcarmo/ghostty-web) (a patched fork with native theme support) instead of xterm.js.
```
┌─────────────┐ ┌──────────────────────────────────────────────────┐
@@ -44,8 +44,8 @@ Key classes:
Manages the mapping between route keys and sessions:
- **TwoWayDict**: Bidirectional mapping of RouteKey ↔ SessionID
- **Session creation**: Creates TerminalSession or AppSession on demand
- **App registry**: Stores app configurations from manifest files
- **Session creation**: Creates TerminalSession on demand
- **App registry**: Stores terminal configurations from manifest files
### terminal_session.py
@@ -229,14 +229,13 @@ Unlike traditional web terminals, sessions survive page refreshes:
## File Structure
```
src/textual_webterm/
src/webterm/
├── cli.py # Click CLI entry point
├── config.py # Configuration parsing (YAML manifests)
├── local_server.py # Main HTTP/WebSocket server
├── session_manager.py # Session registry and routing
├── session.py # Abstract session interface
├── terminal_session.py # PTY-based terminal session
├── app_session.py # Textual app session
├── poller.py # Async I/O polling thread
├── svg_exporter.py # Terminal→SVG renderer
├── docker_stats.py # Docker CPU metrics collector
+18 -18
View File
@@ -67,9 +67,9 @@ Modified:
No action required. The pre-built `terminal.js` bundle is committed to the repo:
```bash
pip install textual-webterm
pip install webterm
# or
pip install git+https://github.com/rcarmo/textual-webterm.git
pip install git+https://github.com/rcarmo/webterm.git
```
Works without needing Node.js or Bun.
@@ -183,7 +183,7 @@ The protocol is simple JSON arrays. Our server already implements this:
**Goal**: Establish Bun-based build pipeline
```
src/textual_webterm/
src/webterm/
├── static/
│ ├── js/
│ │ └── terminal.ts # New: our xterm wrapper
@@ -204,7 +204,7 @@ src/textual_webterm/
**package.json** (final):
```json
{
"name": "textual-webterm-frontend",
"name": "webterm-frontend",
"private": true,
"type": "module",
"dependencies": {
@@ -220,8 +220,8 @@ src/textual_webterm/
"typescript": "^5.7.0"
},
"scripts": {
"build": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --watch --target=browser"
"build": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --watch --target=browser"
}
}
```
@@ -237,7 +237,7 @@ src/textual_webterm/
- [x] Addon initialization (fit, webgl, canvas, unicode11, web-links, clipboard)
- [x] Configurable options via data attributes or window config
See `src/textual_webterm/static/js/terminal.ts` for the full implementation (~230 lines).
See `src/webterm/static/js/terminal.ts` for the full implementation (~230 lines).
### Phase 3: Server Integration ✅
@@ -298,7 +298,7 @@ bundle-watch: node_modules
bun run watch
bundle-clean:
rm -rf node_modules src/textual_webterm/static/js/terminal.js
rm -rf node_modules src/webterm/static/js/terminal.js
node_modules: package.json
bun install
@@ -314,7 +314,7 @@ ENV PATH="/root/.bun/bin:${PATH}"
# Build frontend
COPY package.json bunfig.toml ./
RUN bun install
COPY src/textual_webterm/static/js/terminal.ts src/textual_webterm/static/js/
COPY src/webterm/static/js/terminal.ts src/webterm/static/js/
RUN bun run build
```
@@ -377,11 +377,11 @@ RUN bun run build
# Future: Go Reimplementation
This section analyzes what it would take to reimplement textual-webterm in Go for lighter deployment.
This section analyzes what it would take to reimplement webterm in Go for lighter deployment.
## Status: 📋 Planning
Not yet started. This would be a separate project (`textual-webterm-go`) providing a lightweight alternative.
Not yet started. This would be a separate project (`webterm-go`) providing a lightweight alternative.
## Executive Summary
@@ -407,7 +407,7 @@ Not yet started. This would be a separate project (`textual-webterm-go`) providi
## pyte vs GoPyte: Thorough Comparison
This section compares the Python **pyte** terminal emulator and the Go **GoPyte** emulator (per their upstream documentation/README). The focus is on capture accuracy, Unicode handling, performance expectations, and integration risk for textual-webterm. Where GoPyte details are unclear, we call them out explicitly as validation items.
This section compares the Python **pyte** terminal emulator and the Go **GoPyte** emulator (per their upstream documentation/README). The focus is on capture accuracy, Unicode handling, performance expectations, and integration risk for webterm. Where GoPyte details are unclear, we call them out explicitly as validation items.
### Feature Matrix (Capture-Relevant)
@@ -468,7 +468,7 @@ This section compares the Python **pyte** terminal emulator and the Go **GoPyte*
**Action**: Benchmark with fast-output scenarios, large scrollback, and frequent screenshots.
### Required Features for textual-webterm Capture
### Required Features for webterm Capture
We depend on the emulator for **accurate snapshot state**, not just live display:
@@ -513,7 +513,7 @@ Before relying on GoPyte for parity, verify:
- [ ] Resize correctness (content preservation, cursor placement).
- [ ] Performance at high output rates (100k+ lines, low latency).
### Integration Implications for textual-webterm
### Integration Implications for webterm
- **Screen buffer mapping**: GoPyte cells must map to our SVG exporter schema (fg/bg/bold/underline/reverse).
- **Dirty tracking**: pyte exposes dirty state; GoPyte may need explicit diff tracking.
@@ -546,7 +546,7 @@ These alternatives still need capture parity validation.
| Loss | Impact |
|------|--------|
| **Textual app support** | Cannot run Python Textual apps directly |
| **Textual app support** | Removed in webterm |
| **Rapid prototyping** | Go requires more boilerplate |
| **pyte maturity** | GoPyte is less proven |
@@ -556,7 +556,7 @@ These alternatives still need capture parity validation.
```go
// go.mod
module github.com/rcarmo/textual-webterm-go
module github.com/rcarmo/webterm-go
go 1.22
@@ -771,7 +771,7 @@ README.md # Usage docs
## File Structure (Final)
```
textual-webterm-go/
webterm-go/
├── cmd/
│ └── webterm/
│ └── main.go
@@ -867,7 +867,7 @@ ENTRYPOINT ["/webterm"]
Proceed with Go reimplementation if:
- [ ] Deployment size is critical (embedded, edge, IoT)
- [ ] No need for Textual app support
- [x] No need for Textual app support
- [ ] Want single-binary distribution
- [ ] Memory constraints matter
+3 -3
View File
@@ -7,14 +7,14 @@ class OpenLink(App[None]):
"""Demonstrates opening a URL in the same tab or a new tab."""
def compose(self) -> ComposeResult:
yield Button("Visit the Textual docs", id="open-link-same-tab")
yield Button("Visit the Textual docs in a new tab", id="open-link-new-tab")
yield Button("Visit the terminal docs", id="open-link-same-tab")
yield Button("Visit the terminal docs in a new tab", id="open-link-new-tab")
@on(Button.Pressed)
def open_link(self, event: Button.Pressed) -> None:
"""Open the URL in the same tab or a new tab depending on which button was pressed."""
self.open_url(
"https://textual.textualize.io",
"https://example.com",
new_tab=event.button.id == "open-link-new-tab",
)
+5 -4
View File
@@ -1,12 +1,12 @@
{
"name": "textual-webterm-frontend",
"name": "webterm-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "textual-webterm-frontend",
"name": "webterm-frontend",
"dependencies": {
"ghostty-web": "github:rcarmo/ghostty-web"
"ghostty-web": "github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de"
},
"devDependencies": {
"typescript": "^5.7.0"
@@ -14,7 +14,8 @@
},
"node_modules/ghostty-web": {
"version": "0.4.0-ime-fix",
"resolved": "git+ssh://git@github.com/rcarmo/ghostty-web.git#50fc9127151f7d9d20d5c7bfaea8a6dba8b15bf5",
"resolved": "git+ssh://git@github.com/rcarmo/ghostty-web.git#2837b81646aa0c00bb5a2881b5a53400346c76de",
"integrity": "sha512-NDR8cAy54aBYRz6lL+6r3KPMw/wRIaN3MS+yOEv31cBYgOuFJKkzbDVG/pFXQQJbea6+3w+WKrlOSz74SMHGfw==",
"license": "MIT"
},
"node_modules/typescript": {
+6 -6
View File
@@ -1,18 +1,18 @@
{
"name": "textual-webterm-frontend",
"name": "webterm-frontend",
"private": true,
"type": "module",
"dependencies": {
"ghostty-web": "github:rcarmo/ghostty-web"
"ghostty-web": "github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de"
},
"devDependencies": {
"typescript": "^5.7.0"
},
"scripts": {
"build": "bun run typecheck && bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm src/textual_webterm/static/js/",
"build:fast": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --watch --target=browser",
"build": "bun run typecheck && bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm src/webterm/static/js/",
"build:fast": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --watch --target=browser",
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm src/textual_webterm/static/js/"
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm src/webterm/static/js/"
}
}
+9 -9
View File
@@ -1,15 +1,15 @@
[tool.poetry]
name = "textual-webterm"
version = "1.0.1"
name = "webterm"
version = "1.1.0"
description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
readme = "README.md"
packages = [{include = "textual_webterm", from = "src"}]
packages = [{include = "webterm", from = "src"}]
include = [
{ path = "src/textual_webterm/static/monospace.css" },
{ path = "src/textual_webterm/static/js/terminal.js" },
{ path = "src/textual_webterm/static/js/ghostty-vt.wasm" },
{ path = "src/webterm/static/monospace.css" },
{ path = "src/webterm/static/js/terminal.js" },
{ path = "src/webterm/static/js/ghostty-vt.wasm" },
]
[tool.poetry.dependencies]
@@ -35,7 +35,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
textual-webterm = "textual_webterm.cli:app"
webterm = "webterm.cli:app"
[tool.ruff]
line-length = 100
@@ -71,7 +71,7 @@ ignore = [
]
[tool.ruff.lint.isort]
known-first-party = ["textual_webterm"]
known-first-party = ["webterm"]
[tool.ruff.format]
quote-style = "double"
@@ -95,7 +95,7 @@ markers = [
]
[tool.coverage.run]
source = ["src/textual_webterm"]
source = ["src/webterm"]
branch = true
omit = [
"*/tests/*",
-326
View File
@@ -1,326 +0,0 @@
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
from asyncio import IncompleteReadError, StreamReader, StreamWriter
from datetime import timedelta
from enum import Enum, auto
from time import monotonic
from typing import TYPE_CHECKING
from importlib_metadata import version
from . import constants
from .session import Session, SessionConnector
if TYPE_CHECKING:
from asyncio.subprocess import Process
from pathlib import Path
from .types import Meta, SessionID
log = logging.getLogger("textual-web")
# Maximum payload size to prevent memory exhaustion (16MB)
MAX_PAYLOAD_SIZE = 16 * 1024 * 1024
class ProcessState(Enum):
"""The state of a process."""
PENDING = auto()
RUNNING = auto()
CLOSING = auto()
CLOSED = auto()
def __repr__(self) -> str:
return self.name
class AppSession(Session):
"""Runs a single app process."""
def __init__(
self,
working_directory: Path,
command: str,
session_id: SessionID,
devtools: bool = False,
) -> None:
self.working_directory = working_directory
self.command = command
self.session_id = session_id
self.devtools = devtools
self.start_time: float | None = None
self.end_time: float | None = None
self._process: Process | None = None
self._task: asyncio.Task | None = None
super().__init__()
self._state = ProcessState.PENDING
@property
def process(self) -> Process:
"""The asyncio (sub)process"""
assert self._process is not None
return self._process
@property
def stdin(self) -> StreamWriter:
"""The processes stdin."""
assert self._process is not None
assert self._process.stdin is not None
return self._process.stdin
@property
def stdout(self) -> StreamReader:
"""The process' stdout."""
assert self._process is not None
assert self._process.stdout is not None
return self._process.stdout
@property
def stderr(self) -> StreamReader:
"""The process' stderr."""
assert self._process is not None
assert self._process.stderr is not None
return self._process.stderr
@property
def task(self) -> asyncio.Task:
"""Session task."""
assert self._task is not None
return self._task
@property
def state(self) -> ProcessState:
"""Current running state."""
return self._state
@state.setter
def state(self, state: ProcessState) -> None:
self._state = state
run_time = self.run_time
log.debug(
"%r state=%r run_time=%s",
self,
self.state,
"0" if run_time is None else timedelta(seconds=int(run_time)),
)
@property
def run_time(self) -> float | None:
"""Time process was running, or `None` if it hasn't started."""
if self.end_time is not None:
assert self.start_time is not None
return self.end_time - self.start_time
elif self.start_time is not None:
return monotonic() - self.start_time
else:
return None
def is_running(self) -> bool:
"""Check if the app session is still running."""
return self._state == ProcessState.RUNNING
def __repr__(self) -> str:
returncode = self._process.returncode if self._process else None
return f"<AppSession {self.command!r} id={self.session_id!r} returncode={returncode}>"
async def open(self, width: int = 80, height: int = 24) -> None:
"""Open the process."""
environment = dict(os.environ.copy())
environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver"
environment["TEXTUAL_FPS"] = "60"
environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor"
environment["TERM_PROGRAM"] = "textual-webterm"
environment["TERM_PROGRAM_VERSION"] = version("textual-webterm")
environment["COLUMNS"] = str(width)
environment["ROWS"] = str(height)
if self.devtools:
environment["TEXTUAL"] = "debug,devtools"
environment["TEXTUAL_LOG"] = "textual.log"
# Use cwd parameter instead of os.chdir() for thread safety
self._process = await asyncio.create_subprocess_shell(
self.command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=environment,
cwd=str(self.working_directory),
)
await self.set_terminal_size(width, height)
log.debug("opened %r; %r", self.command, self._process)
self.start_time = monotonic()
async def start(self, connector: SessionConnector) -> asyncio.Task:
"""Start a task to run the process."""
if self._task is not None:
raise RuntimeError("AppSession.start() called while already running")
self._connector = connector
self._task = asyncio.create_task(self.run())
return self._task
async def close(self) -> None:
"""Close the process."""
self.state = ProcessState.CLOSING
await self.send_meta({"type": "quit"})
async def wait(self) -> None:
"""Wait for the process to finish (call close first)."""
if self._task:
await self._task
self._task = None
async def set_terminal_size(self, width: int, height: int) -> None:
"""Set the terminal size for the process.
Args:
width: Width in cells.
height: Height in cells.
"""
await self.send_meta(
{
"type": "resize",
"width": width,
"height": height,
}
)
async def run(self) -> None:
"""This loop reads stdout from the process and relays it through the websocket."""
self.state = ProcessState.RUNNING
META = b"M"
DATA = b"D"
BINARY_ENCODED = b"P"
stderr_data = io.BytesIO()
async def read_stderr() -> None:
"""Task to read stderr."""
try:
while True:
data = await self.stderr.read(1024 * 4)
if not data:
break
stderr_data.write(data)
except asyncio.CancelledError:
pass
stderr_task = asyncio.create_task(read_stderr())
readexactly = self.stdout.readexactly
from_bytes = int.from_bytes
on_data = self._connector.on_data
on_meta = self._connector.on_meta
on_binary_encoded_message = self._connector.on_binary_encoded_message
try:
ready = False
for _ in range(10):
line = await self.stdout.readline()
if not line:
break
if line == b"__GANGLION__\n":
ready = True
break
if ready:
while True:
type_bytes = await readexactly(1)
size_bytes = await readexactly(4)
size = from_bytes(size_bytes, "big")
if size > MAX_PAYLOAD_SIZE:
log.error("Payload size %d exceeds limit %d", size, MAX_PAYLOAD_SIZE)
break
payload = await readexactly(size)
if type_bytes == DATA:
await on_data(payload)
elif type_bytes == META:
meta_data = json.loads(payload)
meta_type = meta_data.get("type")
if meta_type in {"exit", "blur", "focus"}:
await self.send_meta({"type": meta_type})
else:
await on_meta(meta_data)
elif type_bytes == BINARY_ENCODED:
await on_binary_encoded_message(payload)
except IncompleteReadError:
# Incomplete read means that the stream was closed
pass
except asyncio.CancelledError:
pass
finally:
stderr_task.cancel()
await stderr_task
self.end_time = monotonic()
self.state = ProcessState.CLOSED
stderr_message = stderr_data.getvalue().decode("utf-8", errors="replace")
if (
self._process is not None
and self._process.returncode != 0
and constants.DEBUG
and stderr_message
):
log.warning(stderr_message)
await self._connector.on_close()
@classmethod
def encode_packet(cls, packet_type: bytes, payload: bytes) -> bytes:
"""Encode a packet.
Args:
packet_type: The packet type (b"D" for data or b"M" for meta)
payload: The payload.
Returns:
Data as bytes.
"""
return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload)
async def send_bytes(self, data: bytes) -> bool:
"""Send bytes to process.
Args:
data: Data to send.
Returns:
True if the data was sent, otherwise False.
"""
if self._process is None or self._process.stdin is None:
return False
stdin = self._process.stdin
try:
stdin.write(self.encode_packet(b"D", data))
await stdin.drain()
except (RuntimeError, ConnectionResetError, BrokenPipeError):
return False
return True
async def send_meta(self, data: Meta) -> bool:
"""Send meta information to process.
Args:
data: Meta dict to send.
Returns:
True if the data was sent, otherwise False.
"""
if self._process is None or self._process.stdin is None:
return False
stdin = self._process.stdin
data_bytes = json.dumps(data).encode("utf-8")
try:
stdin.write(self.encode_packet(b"M", data_bytes))
await stdin.drain()
except (RuntimeError, ConnectionResetError, BrokenPipeError):
return False
return True
-273
View File
@@ -1,273 +0,0 @@
from __future__ import annotations
import asyncio
import importlib
import importlib.util
import logging
import os
import sys
from pathlib import Path
import click
from importlib_metadata import version
from . import constants
from .local_server import LocalServer
FORMAT = "%(asctime)s %(levelname)s %(message)s"
logging.basicConfig(
level="DEBUG" if constants.DEBUG else "INFO",
format=FORMAT,
datefmt="%X",
)
log = logging.getLogger("textual-webterm")
def _is_file_path(path: str) -> bool:
"""Check if path looks like a file path (vs module path)."""
return path.endswith(".py") or "/" in path or "\\" in path
def parse_app_path(app_path: str) -> tuple[str, str]:
"""Parse an app path like 'module.path:ClassName' or 'path/to/file.py:ClassName'.
Returns:
Tuple of (module_or_file, class_name)
"""
if ":" not in app_path:
raise click.BadParameter(
f"Invalid app path '{app_path}'. Expected format: 'module.path:ClassName' or 'path/to/file.py:ClassName'"
)
module_part, class_name = app_path.rsplit(":", 1)
return module_part, class_name
def load_app_class(app_path: str):
"""Load a Textual App class from a module path.
Args:
app_path: Path like 'module.path:ClassName' or 'path/to/file.py:ClassName'
Returns:
The App class
"""
module_part, class_name = parse_app_path(app_path)
# Check if it's a file path or module path
if _is_file_path(module_part):
# File path - load from file
file_path = Path(module_part).resolve()
if not file_path.exists():
raise click.BadParameter(f"File not found: {file_path}")
# Add parent directory to sys.path for imports
parent_dir = str(file_path.parent)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
# Import the module
module_name = file_path.stem
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise click.BadParameter(f"Could not load module from {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
else:
# Module path - import normally
try:
module = importlib.import_module(module_part)
except ImportError as e:
raise click.BadParameter(f"Could not import module '{module_part}': {e}") from e
# Get the class
if not hasattr(module, class_name):
raise click.BadParameter(f"Module '{module_part}' has no attribute '{class_name}'")
app_class = getattr(module, class_name)
return app_class
@click.command()
@click.version_option(version("textual-webterm"))
@click.argument("command", required=False)
@click.option("--port", "-p", type=int, help="Port for server.", default=8080)
@click.option("--host", "-H", help="Host for server.", default="0.0.0.0")
@click.option(
"--app",
"-a",
"app_path",
help="Load a Textual app from module:ClassName (e.g., 'myapp:MyApp' or 'path/to/app.py:MyApp')",
)
@click.option(
"--landing-manifest",
"-L",
"landing_manifest",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help="YAML manifest describing landing page tiles (slug/name/command).",
)
@click.option(
"--compose-manifest",
"-C",
"compose_manifest",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help='Docker compose YAML; services with label "webterm-command" become landing tiles.',
)
@click.option(
"--docker-watch",
"-D",
"docker_watch",
is_flag=True,
help='Watch Docker for containers with "webterm-command" label and add/remove sessions dynamically.',
)
@click.option(
"--theme",
"-t",
help="Terminal color theme (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo).",
default="xterm",
)
@click.option(
"--font-family",
"-f",
help="Terminal font family (CSS font stack).",
default=None,
)
@click.option(
"--font-size",
"-s",
type=int,
help="Terminal font size in pixels.",
default=16,
)
def app(
command: str | None,
port: int,
host: str,
app_path: str | None,
landing_manifest: Path | None,
compose_manifest: Path | None,
docker_watch: bool,
theme: str,
font_family: str | None,
font_size: int,
) -> None:
"""Serve a terminal or Textual app over HTTP/WebSocket.
COMMAND: Shell command to run in terminal (default: $SHELL)
Examples:
\b
textual-webterm # Serve default shell
textual-webterm htop # Serve htop in terminal
textual-webterm --app mymodule:MyApp # Serve a Textual app from module
textual-webterm -a ./calculator.py:CalculatorApp # Serve from file
textual-webterm --docker-watch # Watch Docker for labeled containers
"""
VERSION = version("textual-webterm")
log.info("textual-webterm v%s", VERSION)
if constants.DEBUG:
log.warning("DEBUG env var is set; logs may be verbose!")
from .config import default_config, load_compose_manifest, load_landing_yaml
_config = default_config()
landing_apps: list = []
is_compose_mode = False
is_docker_watch_mode = docker_watch
compose_project: str | None = None
if landing_manifest:
landing_apps = load_landing_yaml(landing_manifest)
elif compose_manifest:
landing_apps = load_compose_manifest(compose_manifest)
is_compose_mode = True
# Derive compose project name from directory (same as docker-compose default)
compose_project = compose_manifest.parent.name
server = LocalServer(
"./",
_config,
host=host,
port=port,
landing_apps=landing_apps,
compose_mode=is_compose_mode,
compose_project=compose_project,
docker_watch_mode=is_docker_watch_mode,
theme=theme,
font_family=font_family,
font_size=font_size,
)
for app_entry in landing_apps:
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
if app_path:
# Load and run as Textual app from module:class
try:
app_class = load_app_class(app_path)
except click.BadParameter as e:
log.error(str(e))
sys.exit(1)
# Create a command that runs the app using python -m runpy for safety
module_part, class_name = parse_app_path(app_path)
if _is_file_path(module_part):
# File path - use absolute path and proper escaping
file_path = Path(module_part).resolve()
# Use runpy to safely run the file
escaped_path = str(file_path).replace("'", "'\"'\"'")
escaped_class = class_name.replace("'", "'\"'\"'")
run_command = f'python3 -c \'import sys; sys.path.insert(0, "{file_path.parent}"); exec(open("{escaped_path}").read()); {escaped_class}().run()\''
else:
# Module path - validate module and class names
if not module_part.replace(".", "").replace("_", "").isalnum():
log.error("Invalid module path: %s", module_part)
sys.exit(1)
if not class_name.isidentifier():
log.error("Invalid class name: %s", class_name)
sys.exit(1)
run_command = (
f'python3 -c "from {module_part} import {class_name}; {class_name}().run()"'
)
app_name = getattr(app_class, "TITLE", None) or class_name
server.add_app(app_name, run_command, "")
log.info("Serving Textual app: %s", app_path)
elif command:
# Run command as terminal
server.add_terminal("Terminal", command, "")
log.info("Serving terminal: %s", command)
elif docker_watch:
# Docker watch mode - sessions added dynamically
log.info("Docker watch mode enabled - sessions will be added dynamically")
elif not landing_apps:
# Run default shell
terminal_command = os.environ.get("SHELL", "/bin/sh")
server.add_terminal("Terminal", terminal_command, "")
log.info("Serving terminal: %s", terminal_command)
def _run_async():
if constants.WINDOWS:
asyncio.run(server.run())
else:
try:
import uvloop
except ImportError:
asyncio.run(server.run())
else:
if sys.version_info >= (3, 11):
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
runner.run(server.run())
else:
uvloop.install()
asyncio.run(server.run())
_run_async()
if __name__ == "__main__":
app()
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import asyncio
import logging
import os
import sys
from pathlib import Path
import click
from importlib_metadata import PackageNotFoundError, version
from . import constants
from .local_server import LocalServer
FORMAT = "%(asctime)s %(levelname)s %(message)s"
logging.basicConfig(
level="DEBUG" if constants.DEBUG else "INFO",
format=FORMAT,
datefmt="%X",
)
log = logging.getLogger("webterm")
def _package_version() -> str:
try:
return version("webterm")
except PackageNotFoundError:
return "0.0.0"
@click.command()
@click.version_option(_package_version())
@click.argument("command", required=False)
@click.option("--port", "-p", type=int, help="Port for server.", default=8080)
@click.option("--host", "-H", help="Host for server.", default="0.0.0.0")
@click.option(
"--landing-manifest",
"-L",
"landing_manifest",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help="YAML manifest describing landing page tiles (slug/name/command).",
)
@click.option(
"--compose-manifest",
"-C",
"compose_manifest",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help='Docker compose YAML; services with label "webterm-command" become landing tiles.',
)
@click.option(
"--docker-watch",
"-D",
"docker_watch",
is_flag=True,
help='Watch Docker for containers with "webterm-command" label and add/remove sessions dynamically.',
)
@click.option(
"--theme",
"-t",
help="Terminal color theme (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo).",
default="xterm",
)
@click.option(
"--font-family",
"-f",
help="Terminal font family (CSS font stack).",
default=None,
)
@click.option(
"--font-size",
"-s",
type=int,
help="Terminal font size in pixels.",
default=16,
)
def app(
command: str | None,
port: int,
host: str,
landing_manifest: Path | None,
compose_manifest: Path | None,
docker_watch: bool,
theme: str,
font_family: str | None,
font_size: int,
) -> None:
"""Serve a terminal over HTTP/WebSocket.
COMMAND: Shell command to run in terminal (default: $SHELL)
Examples:
\b
webterm # Serve default shell
webterm htop # Serve htop in terminal
webterm --docker-watch # Watch Docker for labeled containers
"""
VERSION = _package_version()
log.info("webterm v%s", VERSION)
if constants.DEBUG:
log.warning("DEBUG env var is set; logs may be verbose!")
from .config import default_config, load_compose_manifest, load_landing_yaml
_config = default_config()
landing_apps: list = []
is_compose_mode = False
is_docker_watch_mode = docker_watch
compose_project: str | None = None
if landing_manifest:
landing_apps = load_landing_yaml(landing_manifest)
elif compose_manifest:
landing_apps = load_compose_manifest(compose_manifest)
is_compose_mode = True
# Derive compose project name from directory (same as docker-compose default)
compose_project = compose_manifest.parent.name
server = LocalServer(
"./",
_config,
host=host,
port=port,
landing_apps=landing_apps,
compose_mode=is_compose_mode,
compose_project=compose_project,
docker_watch_mode=is_docker_watch_mode,
theme=theme,
font_family=font_family,
font_size=font_size,
)
for app_entry in landing_apps:
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
if command:
# Run command as terminal
server.add_terminal("Terminal", command, "")
log.info("Serving terminal: %s", command)
elif docker_watch:
# Docker watch mode - sessions added dynamically
log.info("Docker watch mode enabled - sessions will be added dynamically")
elif not landing_apps:
# Run default shell
terminal_command = os.environ.get("SHELL", "/bin/sh")
server.add_terminal("Terminal", terminal_command, "")
log.info("Serving terminal: %s", terminal_command)
def _run_async():
if constants.WINDOWS:
asyncio.run(server.run())
else:
try:
import uvloop
except ImportError:
asyncio.run(server.run())
else:
if sys.version_info >= (3, 11):
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
runner.run(server.run())
else:
uvloop.install()
asyncio.run(server.run())
_run_async()
if __name__ == "__main__":
app()
@@ -31,7 +31,6 @@ class Config(BaseModel):
"""Root configuration model."""
apps: list[App] = Field(default_factory=list)
landing: list[App] = Field(default_factory=list)
def default_config() -> Config:
@@ -65,11 +64,12 @@ def load_config(config_path: Path) -> Config:
return App(**data)
apps = [make_app(name, app) for name, app in config_data.get("app", {}).items()]
terminal_entries = config_data.get("terminal", {})
app_entries = config_data.get("app", {})
if app_entries:
raise ValueError("App manifests are no longer supported; use [terminal.*] entries only.")
apps += [
make_app(name, app, terminal=True) for name, app in config_data.get("terminal", {}).items()
]
apps = [make_app(name, app, terminal=True) for name, app in terminal_entries.items()]
config = Config(apps=apps)
@@ -14,7 +14,7 @@ import socket
from collections import deque
from pathlib import Path
log = logging.getLogger("textual-webterm")
log = logging.getLogger("webterm")
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
@@ -17,7 +17,7 @@ from .docker_stats import get_docker_socket_path
if TYPE_CHECKING:
from .session_manager import SessionManager
log = logging.getLogger("textual-webterm")
log = logging.getLogger("webterm")
LABEL_NAME = "webterm-command"
DEFAULT_COMMAND = "/bin/bash"
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
EXIT_POLL_RATE = 5
log = logging.getLogger("textual-web")
log = logging.getLogger("webterm")
if TYPE_CHECKING:
from .local_server import LocalServer
@@ -27,7 +27,7 @@ from .types import Meta, RouteKey, SessionID
if TYPE_CHECKING:
from .config import Config
log = logging.getLogger("textual-web")
log = logging.getLogger("webterm")
DEFAULT_TERMINAL_SIZE = (132, 45)
@@ -127,7 +127,7 @@ class LocalServer:
return 5.0
return SCREENSHOT_MAX_CACHE_SECONDS
"""Manages local Textual apps and terminals without Ganglion server."""
"""Manages local terminal sessions without Ganglion server."""
def __init__(
self,
@@ -194,7 +194,7 @@ class LocalServer:
def add_terminal(self, name: str, command: str, slug: str = "") -> None:
if constants.WINDOWS:
log.warning("Sorry, textual-web does not currently support terminals on Windows")
log.warning("Sorry, webterm does not currently support terminals on Windows")
return
slug = slug or generate().lower()
self.session_manager.add_app(name, command, slug=slug, terminal=True)
@@ -571,7 +571,7 @@ class LocalServer:
screen_buffer,
width=screen_width,
height=screen_height,
title="textual-webterm",
title="webterm",
)
svg = await asyncio.to_thread(_render_svg)
@@ -944,11 +944,11 @@ class LocalServer:
html_content = """<!DOCTYPE html>
<html>
<head>
<title>Textual Web Terminal Server</title>
<title>Webterm Server</title>
</head>
<body>
<h2>No Apps Available</h2>
<p>No terminal or Textual applications are configured.</p>
<p>No terminal applications are configured.</p>
</body>
</html>"""
return web.Response(text=html_content, content_type="text/html")
@@ -965,14 +965,17 @@ class LocalServer:
route_key = RouteKey(generate().lower())
ws_url = self._get_ws_url_from_request(request, route_key)
page_title = available_app.name if available_app else "Textual Web Terminal"
page_title = available_app.name if available_app else "Webterm"
# Build data attributes for terminal configuration
data_attrs = f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" data-scrollback="1000" data-theme="{self.theme}"'
if self.font_family:
# Escape quotes for HTML attribute
escaped_font = self.font_family.replace('"', "&quot;")
data_attrs += f' data-font-family="{escaped_font}"'
data_attrs = (
f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" '
f'data-scrollback="1000" data-theme="{self.theme}"'
)
font_family = self.font_family or "var(--webterm-mono)"
# Escape quotes for HTML attribute
escaped_font = font_family.replace('"', "&quot;")
data_attrs += f' data-font-family="{escaped_font}"'
# Get theme background color (fallback to black if unknown theme)
theme_bg = THEME_BACKGROUNDS.get(self.theme.lower(), "#000000")
@@ -985,11 +988,11 @@ class LocalServer:
<style>
html, body {{ width: 100%; height: 100%; }}
body {{ background: {theme_bg}; margin: 0; padding: 0; overflow: hidden; }}
.textual-terminal {{ width: 100%; height: 100%; display: block; overflow: hidden; }}
.webterm-terminal {{ width: 100%; height: 100%; display: block; overflow: hidden; }}
</style>
</head>
<body>
<div id=\"terminal\" class=\"textual-terminal\" {data_attrs}></div>
<div id=\"terminal\" class=\"webterm-terminal\" {data_attrs}></div>
<script type=\"module\" src=\"/static/js/terminal.js\"></script>
</body>
</html>"""
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING
from . import config, constants
from ._two_way_dict import TwoWayDict
from .app_session import AppSession
from .identity import generate
if TYPE_CHECKING:
@@ -18,7 +17,7 @@ if TYPE_CHECKING:
from .types import RouteKey, SessionID
log = logging.getLogger("textual-web")
log = logging.getLogger("webterm")
if not constants.WINDOWS:
@@ -26,7 +25,7 @@ if not constants.WINDOWS:
class SessionManager:
"""Manage sessions (Textual apps or terminals)."""
"""Manage terminal sessions."""
def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None:
self.poller = poller
@@ -122,24 +121,16 @@ class SessionManager:
return None
session_process: Session
if app.terminal:
if constants.WINDOWS:
log.warning("Sorry, textual-web does not currently support terminals on Windows")
return None
else:
session_process = TerminalSession(
self.poller,
session_id,
app.command,
)
log.info("Created terminal session %s", session_id)
else:
session_process = AppSession(
self.path,
app.command,
session_id,
)
log.info("Created app session %s", session_id)
if constants.WINDOWS:
log.warning("Sorry, webterm does not currently support terminals on Windows")
return None
session_process = TerminalSession(
self.poller,
session_id,
app.command,
)
log.info("Created terminal session %s", session_id)
# Open the session BEFORE registering it, so it's fully initialized
# when other code can access it via sessions/routes dicts
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
/**
* ghostty-web terminal client for textual-webterm.
* ghostty-web terminal client for webterm.
*
* Implements the WebSocket protocol compatible with local_server.py:
* - Client Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
@@ -409,10 +409,12 @@ class WebTerminal {
// Build terminal options
const themeToUse = config.theme ?? THEMES.xterm;
console.log("[webterm:create] Theme to use (config.theme ?? THEMES.xterm):", JSON.stringify(themeToUse, null, 2));
const fontFamily = config.fontFamily?.trim() || DEFAULT_FONT_FAMILY;
const fontSize = config.fontSize ?? 16;
const options: ITerminalOptions = {
fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY,
fontSize: config.fontSize ?? 16,
fontFamily,
fontSize,
scrollback: config.scrollback ?? 1000,
cursorBlink: true,
cursorStyle: "block",
@@ -456,8 +458,8 @@ class WebTerminal {
wsUrl,
terminal,
fitAddon,
options.fontFamily ?? DEFAULT_FONT_FAMILY,
options.fontSize ?? 16
fontFamily,
fontSize
);
console.log("[webterm:create] WebTerminal instance created");
instance.initialize();
@@ -488,7 +490,11 @@ class WebTerminal {
// Wait for fonts to load before fitting to ensure correct measurements
this.waitForFonts().then(() => {
console.log("[webterm:init] Fonts loaded, calling fit()...");
console.log("[webterm:init] Fonts loaded, reapplying font family and fitting...");
this.terminal.options.fontFamily = this.fontFamily;
if (typeof (this.terminal as unknown as { loadFonts?: () => void }).loadFonts === "function") {
(this.terminal as unknown as { loadFonts: () => void }).loadFonts();
}
this.fit();
console.log("[webterm:init] fit() completed");
@@ -1151,8 +1157,8 @@ const instances: Map<HTMLElement, WebTerminal> = new Map();
/** Initialize all terminal containers on page load */
async function initTerminals(): Promise<void> {
console.log("[webterm:init] initTerminals() called");
const containers = document.querySelectorAll<HTMLElement>(".textual-terminal");
console.log(`[webterm:init] Found ${containers.length} .textual-terminal containers`);
const containers = document.querySelectorAll<HTMLElement>(".webterm-terminal");
console.log(`[webterm:init] Found ${containers.length} .webterm-terminal containers`);
for (const el of containers) {
console.log("[webterm:init] Processing container:", el);
@@ -1160,7 +1166,7 @@ async function initTerminals(): Promise<void> {
const wsUrl = el.dataset.sessionWebsocketUrl;
if (!wsUrl) {
console.error("Missing data-session-websocket-url on terminal container");
console.error("Missing data-session-websocket-url on terminal container");
continue;
}
@@ -5,7 +5,7 @@ We avoid external font fetching (e.g. Google Fonts) to keep local server self-co
*/
:root {
--textual-webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font",
--webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font",
"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas,
"Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;
--terminal-min-width: 10px;
@@ -19,7 +19,7 @@ html, body {
padding: 0;
overflow: hidden;
box-sizing: border-box;
font-family: var(--textual-webterm-mono);
font-family: var(--webterm-mono);
}
/* Prevent scrollbar gutter space reservation */
@@ -29,7 +29,7 @@ html {
}
/* Terminal container - works with ghostty-web canvas renderer */
.textual-terminal {
.webterm-terminal {
width: 100%;
height: 100%;
display: flex;
@@ -42,7 +42,7 @@ html {
}
/* ghostty-web renders to a canvas element */
.textual-terminal canvas {
.webterm-terminal canvas {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
@@ -52,7 +52,7 @@ html {
}
/* Hidden textarea for keyboard input */
.textual-terminal textarea {
.webterm-terminal textarea {
position: absolute;
opacity: 0;
pointer-events: none;
@@ -60,14 +60,14 @@ html {
/* High DPI display handling */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.textual-terminal {
.webterm-terminal {
/* ghostty-web handles DPI scaling via devicePixelRatio */
}
}
/* Fallback for older browsers */
@supports not (display: flex) {
.textual-terminal {
.webterm-terminal {
display: block;
position: absolute;
top: 0;
@@ -14,7 +14,7 @@ from collections import deque
from typing import TYPE_CHECKING
import pyte
from importlib_metadata import version
from importlib_metadata import PackageNotFoundError, version
from .session import Session, SessionConnector
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
from .poller import Poller
from .types import Meta, SessionID
log = logging.getLogger("textual-web")
log = logging.getLogger("webterm")
# Maximum bytes to keep in replay buffer for reconnection
REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB
@@ -62,6 +62,13 @@ class TerminalSession(Session):
def __repr__(self) -> str:
return f"TerminalSession(session_id={self.session_id!r}, command={self.command!r})"
@staticmethod
def _package_version() -> str:
try:
return version("webterm")
except PackageNotFoundError:
return "0.0.0"
async def open(self, width: int = 80, height: int = 24) -> None:
log.info("Opening terminal session %s with command: %s", self.session_id, self.command)
# Track the initial size
@@ -76,8 +83,8 @@ class TerminalSession(Session):
self.pid = pid
self.master_fd = master_fd
if pid == pty.CHILD:
os.environ["TERM_PROGRAM"] = "textual-webterm"
os.environ["TERM_PROGRAM_VERSION"] = version("textual-webterm")
os.environ["TERM_PROGRAM"] = "webterm"
os.environ["TERM_PROGRAM_VERSION"] = self._package_version()
try:
argv = shlex.split(self.command)
except ValueError:
+1 -1
View File
@@ -1 +1 @@
"""Tests for textual-webterm."""
"""Tests for webterm."""
+5 -5
View File
@@ -1,4 +1,4 @@
"""Pytest configuration and fixtures for textual-webterm tests."""
"""Pytest configuration and fixtures for webterm tests."""
from __future__ import annotations
@@ -8,10 +8,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from textual_webterm.config import App, Config
from textual_webterm.local_server import LocalServer
from textual_webterm.poller import Poller
from textual_webterm.session_manager import SessionManager
from webterm.config import App, Config
from webterm.local_server import LocalServer
from webterm.poller import Poller
from webterm.session_manager import SessionManager
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator
-193
View File
@@ -1,193 +0,0 @@
"""Tests for app_session module."""
import asyncio
import contextlib
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual_webterm.app_session import AppSession, ProcessState
class TestProcessState:
"""Tests for ProcessState enum."""
def test_process_states_exist(self):
"""Test that all process states exist."""
assert ProcessState.PENDING is not None
assert ProcessState.RUNNING is not None
assert ProcessState.CLOSED is not None
class TestAppSession:
"""Tests for AppSession class."""
@pytest.fixture
def mock_path(self, tmp_path):
"""Create a mock path."""
return tmp_path
def test_init(self, mock_path):
"""Test AppSession initialization."""
session = AppSession(mock_path, "python app.py", "test-session")
assert session.working_directory == mock_path
assert session.command == "python app.py"
assert session.session_id == "test-session"
assert session.state == ProcessState.PENDING
def test_init_with_devtools(self, mock_path):
"""Test AppSession with devtools enabled."""
session = AppSession(mock_path, "python app.py", "test-session", devtools=True)
assert session.devtools is True
@pytest.mark.asyncio
async def test_send_bytes_not_running(self, mock_path):
"""Test send_bytes when not running returns False."""
session = AppSession(mock_path, "python app.py", "test-session")
# Session not started, will return False gracefully
result = await session.send_bytes(b"test")
assert result is False
@pytest.mark.asyncio
async def test_send_meta(self, mock_path):
"""Test send_meta."""
session = AppSession(mock_path, "python app.py", "test-session")
session._process = MagicMock()
session._process.stdin = MagicMock()
session._process.stdin.write = MagicMock()
session._process.stdin.drain = AsyncMock()
await session.send_meta({"key": "value"})
# Should handle meta data
@pytest.mark.asyncio
async def test_set_terminal_size(self, mock_path):
"""Test set_terminal_size."""
session = AppSession(mock_path, "python app.py", "test-session")
session._process = MagicMock()
session._process.stdin = MagicMock()
session._process.stdin.write = MagicMock()
session._process.stdin.drain = AsyncMock()
# Should not raise
await session.set_terminal_size(100, 50)
@pytest.mark.asyncio
async def test_close_not_running(self, mock_path):
"""Test close when not running handles gracefully."""
session = AppSession(mock_path, "python app.py", "test-session")
# No process running, close should handle gracefully (not crash)
await session.close()
assert session.state == ProcessState.CLOSING
@pytest.mark.asyncio
async def test_wait_no_task(self, mock_path):
"""Test wait when no task."""
session = AppSession(mock_path, "python app.py", "test-session")
# Should not raise
await session.wait()
def test_state_transitions(self, mock_path):
"""Test state transition tracking."""
session = AppSession(mock_path, "python app.py", "test-session")
assert session.state == ProcessState.PENDING
# Manually set state for testing
session.state = ProcessState.RUNNING
assert session.state == ProcessState.RUNNING
session.state = ProcessState.CLOSED
assert session.state == ProcessState.CLOSED
class TestAppSessionConnector:
"""Tests for AppSession with connector."""
@pytest.fixture
def mock_connector(self):
"""Create a mock connector."""
connector = MagicMock()
connector.on_data = AsyncMock()
connector.on_meta = AsyncMock()
connector.on_binary_encoded_message = AsyncMock()
connector.on_close = AsyncMock()
return connector
@pytest.mark.asyncio
async def test_start_creates_task(self, tmp_path, mock_connector):
"""Test that start creates a task."""
session = AppSession(tmp_path, "echo test", "test-session")
with (
patch.object(session, "open", new_callable=AsyncMock),
patch.object(session, "run", new_callable=AsyncMock),
):
task = await session.start(mock_connector)
assert task is not None
# Cancel to clean up
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
def test_encode_packet(self, tmp_path):
session = AppSession(tmp_path, "echo test", "sid")
packet = session.encode_packet(b"D", b"abc")
assert packet[:1] == b"D"
assert packet[1:5] == (3).to_bytes(4, "big")
assert packet[5:] == b"abc"
@pytest.mark.asyncio
async def test_send_bytes_handles_broken_pipe(self, tmp_path):
session = AppSession(tmp_path, "echo test", "sid")
stdin = MagicMock()
stdin.write = MagicMock(side_effect=BrokenPipeError())
stdin.drain = AsyncMock()
session._process = MagicMock(stdin=stdin)
assert await session.send_bytes(b"x") is False
@pytest.mark.asyncio
async def test_send_meta_encodes_json_and_writes(self, tmp_path):
session = AppSession(tmp_path, "echo test", "sid")
stdin = MagicMock()
stdin.write = MagicMock()
stdin.drain = AsyncMock()
session._process = MagicMock(stdin=stdin)
meta = {"type": "hello", "n": 1}
assert await session.send_meta(meta) is True
written = stdin.write.call_args.args[0]
assert written[:1] == b"M"
payload = written[5:]
assert json.loads(payload.decode("utf-8")) == meta
@pytest.mark.asyncio
async def test_open_sets_env_and_cwd(self, tmp_path, monkeypatch):
session = AppSession(tmp_path, "echo test", "sid", devtools=True)
fake_proc = MagicMock()
fake_proc.stdin = MagicMock()
fake_proc.stdout = MagicMock()
fake_proc.stderr = MagicMock()
async def fake_create(command, **kwargs):
assert command == "echo test"
assert kwargs["cwd"] == str(tmp_path)
env = kwargs["env"]
assert env["COLUMNS"] == "100"
assert env["ROWS"] == "40"
assert "TEXTUAL" in env
return fake_proc
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
monkeypatch.setattr(session, "set_terminal_size", AsyncMock())
await session.open(width=100, height=40)
assert session._process is fake_proc
# run() packet decoding coverage is exercised in test_app_session_run_packets.py
-113
View File
@@ -1,113 +0,0 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from textual_webterm.app_session import AppSession
@pytest.fixture
def mock_connector():
connector = MagicMock()
connector.on_data = AsyncMock()
connector.on_meta = AsyncMock()
connector.on_binary_encoded_message = AsyncMock()
connector.on_close = AsyncMock()
return connector
@pytest.mark.asyncio
async def test_run_decodes_packets_and_forwards(tmp_path, mock_connector, monkeypatch):
from textual_webterm import app_session
session = AppSession(tmp_path, "echo test", "sid")
session._connector = mock_connector
session.start_time = 0.0
stdin = MagicMock()
stdin.write = MagicMock()
stdin.drain = AsyncMock()
stdout = MagicMock()
# Provide a second empty line so AppSession's readiness loop terminates cleanly.
stdout.readline = AsyncMock(side_effect=[b"__GANGLION__\n", b""])
payload_data = b"hello"
payload_meta = json.dumps({"type": "custom", "x": 1}).encode("utf-8")
payload_meta_exit = json.dumps({"type": "exit"}).encode("utf-8")
payload_bin = b"\x00\x01"
read_parts = [
b"D",
len(payload_data).to_bytes(4, "big"),
payload_data,
b"M",
len(payload_meta).to_bytes(4, "big"),
payload_meta,
b"M",
len(payload_meta_exit).to_bytes(4, "big"),
payload_meta_exit,
b"P",
len(payload_bin).to_bytes(4, "big"),
payload_bin,
]
async def readexactly(n: int) -> bytes:
await asyncio.sleep(0)
if not read_parts:
raise asyncio.IncompleteReadError(partial=b"", expected=n)
part = read_parts.pop(0)
assert len(part) == n
return part
stdout.readexactly = AsyncMock(side_effect=readexactly)
stderr = MagicMock()
stderr.read = AsyncMock(return_value=b"")
session._process = MagicMock(stdin=stdin, stdout=stdout, stderr=stderr, returncode=0)
monkeypatch.setattr(app_session.constants, "DEBUG", False)
await session.run()
mock_connector.on_data.assert_awaited_once_with(payload_data)
mock_connector.on_meta.assert_awaited_once_with({"type": "custom", "x": 1})
mock_connector.on_binary_encoded_message.assert_awaited_once_with(payload_bin)
assert stdin.write.called
mock_connector.on_close.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_payload_too_large_breaks_loop(tmp_path, mock_connector, monkeypatch):
from textual_webterm import app_session
session = AppSession(tmp_path, "echo test", "sid")
session._connector = mock_connector
session.start_time = 0.0
stdin = MagicMock()
stdin.write = MagicMock()
stdin.drain = AsyncMock()
stdout = MagicMock()
stdout.readline = AsyncMock(side_effect=[b"__GANGLION__\n", b""])
async def readexactly(n: int) -> bytes:
await asyncio.sleep(0)
if n == 1:
return b"D"
if n == 4:
return (app_session.MAX_PAYLOAD_SIZE + 1).to_bytes(4, "big")
raise asyncio.IncompleteReadError(partial=b"", expected=n)
stdout.readexactly = AsyncMock(side_effect=readexactly)
stderr = MagicMock()
stderr.read = AsyncMock(return_value=b"")
session._process = MagicMock(stdin=stdin, stdout=stdout, stderr=stderr, returncode=0)
monkeypatch.setattr(app_session.constants, "DEBUG", False)
await session.run()
mock_connector.on_close.assert_awaited_once()
+62 -127
View File
@@ -1,85 +1,8 @@
"""Tests for CLI module."""
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
class TestParseAppPath:
"""Tests for parse_app_path function."""
def test_parse_module_class(self):
"""Test parsing module:class format."""
from textual_webterm.cli import parse_app_path
module, cls = parse_app_path("mymodule:MyClass")
assert module == "mymodule"
assert cls == "MyClass"
def test_parse_nested_module_class(self):
"""Test parsing nested.module:class format."""
from textual_webterm.cli import parse_app_path
module, cls = parse_app_path("my.nested.module:MyClass")
assert module == "my.nested.module"
assert cls == "MyClass"
def test_parse_file_path_class(self):
"""Test parsing file/path.py:class format."""
from textual_webterm.cli import parse_app_path
module, cls = parse_app_path("path/to/file.py:MyClass")
assert module == "path/to/file.py"
assert cls == "MyClass"
def test_parse_no_colon_raises(self):
"""Test that missing colon raises BadParameter."""
from textual_webterm.cli import parse_app_path
with pytest.raises(click.BadParameter) as exc_info:
parse_app_path("invalid_format")
assert "Expected format" in str(exc_info.value)
class TestLoadAppClass:
"""Tests for load_app_class function."""
def test_load_nonexistent_module(self):
"""Test loading from non-existent module raises."""
from textual_webterm.cli import load_app_class
with pytest.raises(click.BadParameter) as exc_info:
load_app_class("nonexistent_module_xyz:MyClass")
assert "Could not import" in str(exc_info.value)
def test_load_nonexistent_class(self):
"""Test loading non-existent class from existing module raises."""
from textual_webterm.cli import load_app_class
with pytest.raises(click.BadParameter) as exc_info:
load_app_class("os:NonExistentClass")
assert "has no attribute" in str(exc_info.value)
def test_load_existing_class(self):
"""Test loading an existing class from a module."""
from textual_webterm.cli import load_app_class
# Load Path from pathlib
cls = load_app_class("pathlib:Path")
assert cls is Path
def test_load_from_file_nonexistent(self):
"""Test loading from non-existent file raises."""
from textual_webterm.cli import load_app_class
with pytest.raises(click.BadParameter) as exc_info:
load_app_class("/nonexistent/path.py:MyClass")
assert (
"not found" in str(exc_info.value).lower()
or "does not exist" in str(exc_info.value).lower()
)
from webterm import cli
class TestCLI:
@@ -87,7 +10,7 @@ class TestCLI:
def test_cli_help(self):
"""Test CLI help output."""
from textual_webterm.cli import app as cli_app
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
@@ -95,8 +18,6 @@ class TestCLI:
assert "terminal" in result.output.lower() or "command" in result.output.lower()
def test_cli_runs_terminal_command(self, monkeypatch):
from textual_webterm import cli
calls: dict[str, object] = {}
class FakeServer:
@@ -119,9 +40,6 @@ class TestCLI:
def test_cli_runs_default_shell(self, monkeypatch):
import os
from textual_webterm import cli
calls: dict[str, object] = {}
class FakeServer:
@@ -143,33 +61,18 @@ class TestCLI:
assert result.exit_code == 0
assert calls["terminal"][1] == os.environ["SHELL"]
def test_cli_app_module_validation_rejects(self):
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--app", "os;rm -rf /:Fake"])
assert result.exit_code != 0
def test_cli_version(self):
"""Test CLI version output."""
from textual_webterm.cli import app as cli_app
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--version"])
assert result.exit_code == 0
assert "version" in result.output
def test_cli_invalid_app_path(self):
"""Test CLI with invalid app path."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--app", "invalid"])
assert result.exit_code != 0
def test_cli_port_option(self):
"""Test CLI port option parsing."""
from textual_webterm.cli import app as cli_app
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
@@ -177,51 +80,83 @@ class TestCLI:
def test_cli_host_option(self):
"""Test CLI host option parsing."""
from textual_webterm.cli import app as cli_app
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--host" in result.output or "-H" in result.output
class TestModuleValidation:
"""Tests for module/class name validation in CLI."""
def test_invalid_module_characters(self):
"""Test that invalid module names are rejected."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
# Module with shell characters should be rejected or fail gracefully
result = runner.invoke(cli_app, ["--app", "os; rm -rf /:Fake"])
# Should not succeed
assert result.exit_code != 0
def test_invalid_class_name(self):
"""Test that invalid class names are rejected."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--app", "os:123invalid"])
assert result.exit_code != 0
class TestCLIOptions:
"""Tests for CLI option handling."""
def test_debug_option(self):
"""Test --debug option exists."""
from textual_webterm.cli import app as cli_app
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--app" in result.output
assert "--docker-watch" in result.output
def test_no_run_option(self):
"""Test --no-run option exists."""
from textual_webterm.cli import app as cli_app
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
# Check that basic options are documented
assert "port" in result.output.lower()
def test_package_version_fallback(monkeypatch):
def raise_missing(_name: str):
raise cli.PackageNotFoundError("webterm")
monkeypatch.setattr(cli, "version", raise_missing)
assert cli._package_version() == "0.0.0"
def test_cli_docker_watch_mode(monkeypatch):
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_terminal(self, name, command, slug):
calls["terminal"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: calls.setdefault("run", True))
monkeypatch.setattr(cli.constants, "DEBUG", True)
runner = CliRunner()
result = runner.invoke(cli.app, ["--docker-watch"])
assert result.exit_code == 0
assert "terminal" not in calls
def test_cli_windows_branch(monkeypatch):
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_terminal(self, name, command, slug):
calls["terminal"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.constants, "WINDOWS", True)
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: calls.setdefault("run", True))
runner = CliRunner()
result = runner.invoke(cli.app, ["--docker-watch"])
assert result.exit_code == 0
assert calls.get("run") is True
+2 -2
View File
@@ -3,9 +3,10 @@ from pathlib import Path
from click.testing import CliRunner
from webterm import cli
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
from textual_webterm import cli
manifest = tmp_path / "landing.yaml"
manifest.write_text(
@@ -39,7 +40,6 @@ def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
from textual_webterm import cli
manifest = tmp_path / "compose.yaml"
manifest.write_text(
-55
View File
@@ -1,55 +0,0 @@
"""Extra CLI coverage tests for app execution paths."""
from __future__ import annotations
from click.testing import CliRunner
def test_cli_runs_app_from_file(monkeypatch, tmp_path):
from textual_webterm import cli
app_file = tmp_path / "myapp.py"
app_file.write_text(
"""
class MyApp:
TITLE = "MyApp"
def run(self):
return 0
""".lstrip()
)
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_app(self, name, command, slug):
calls["app"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None)
runner = CliRunner()
result = runner.invoke(cli.app, ["--app", f"{app_file}:MyApp"])
assert result.exit_code == 0
assert calls["app"][0] == "MyApp"
assert "python3" in calls["app"][1]
def test_load_app_class_from_file(tmp_path):
from textual_webterm.cli import load_app_class
app_file = tmp_path / "myapp2.py"
app_file.write_text(
"""
class MyApp2:
pass
""".lstrip()
)
cls = load_app_class(f"{app_file}:MyApp2")
assert cls.__name__ == "MyApp2"
+16 -18
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from textual_webterm.config import App, Config
import pytest
from webterm.config import App, Config
class TestApp:
@@ -21,13 +23,12 @@ class TestApp:
assert app.terminal is True
assert app.command == "bash"
def test_create_textual_app(self) -> None:
"""Test creating a Textual app configuration."""
def test_create_terminal_app_defaults(self) -> None:
"""Test creating a terminal app configuration with defaults."""
app = App(
name="My App",
slug="my-app",
terminal=False,
command="python -m myapp",
command="bash",
)
assert app.terminal is False
@@ -53,7 +54,7 @@ class TestDefaultConfig:
def test_default_config_returns_config(self):
"""Test that default_config returns a Config object."""
from textual_webterm.config import default_config
from webterm.config import default_config
config = default_config()
assert config is not None
@@ -63,27 +64,24 @@ class TestDefaultConfig:
class TestLoadConfig:
"""Tests for load_config function."""
def test_load_config_parses_app_and_terminal(self, tmp_path):
from textual_webterm.config import load_config
def test_load_config_parses_terminal_only(self, tmp_path):
from webterm.config import load_config
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[app.demo]
command = "echo demo"
[terminal.shell]
command = "bash"
""".lstrip()
)
config = load_config(config_path)
assert len(config.apps) == 2
assert {a.name for a in config.apps} == {"demo", "shell"}
assert len(config.apps) == 1
assert {a.name for a in config.apps} == {"shell"}
assert any(a.terminal for a in config.apps)
def test_load_config_slugify_for_app(self, tmp_path):
from textual_webterm.config import load_config
def test_load_config_rejects_app_entries(self, tmp_path):
from webterm.config import load_config
config_path = tmp_path / "config.toml"
config_path.write_text(
@@ -92,11 +90,11 @@ command = "bash"
command = "echo hi"
""".lstrip()
)
config = load_config(config_path)
assert config.apps[0].slug
with pytest.raises(ValueError):
load_config(config_path)
def test_load_config_expands_vars(self, tmp_path, monkeypatch):
from textual_webterm.config import load_config
from webterm.config import load_config
monkeypatch.setenv("MY_CMD", "echo expanded")
config_path = tmp_path / "config.toml"
+1 -1
View File
@@ -1,7 +1,7 @@
import tempfile
from pathlib import Path
from textual_webterm.config import load_compose_manifest, load_landing_yaml
from webterm.config import load_compose_manifest, load_landing_yaml
def test_load_landing_yaml_simple():
+4 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
def test_get_environ_bool(monkeypatch):
from textual_webterm.constants import get_environ_bool
from webterm.constants import get_environ_bool
monkeypatch.setenv("FLAG", "1")
assert get_environ_bool("FLAG") is True
@@ -14,21 +14,21 @@ def test_get_environ_bool(monkeypatch):
def test_get_environ_int_keyerror(monkeypatch):
from textual_webterm.constants import get_environ_int
from webterm.constants import get_environ_int
monkeypatch.delenv("INT", raising=False)
assert get_environ_int("INT", 7) == 7
def test_get_environ_int_valueerror(monkeypatch):
from textual_webterm.constants import get_environ_int
from webterm.constants import get_environ_int
monkeypatch.setenv("INT", "not-an-int")
assert get_environ_int("INT", 7) == 7
def test_get_environ_int_valid(monkeypatch):
from textual_webterm.constants import get_environ_int
from webterm.constants import get_environ_int
monkeypatch.setenv("INT", "42")
assert get_environ_int("INT", 7) == 42
+7 -7
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
import pytest
from textual_webterm.docker_stats import (
from webterm.docker_stats import (
STATS_HISTORY_SIZE,
DockerStatsCollector,
render_sparkline_svg,
@@ -177,8 +177,8 @@ class TestLocalServerSparklineEndpoint:
"""Missing container param returns 400."""
from aiohttp.web import HTTPBadRequest
from textual_webterm.config import Config
from textual_webterm.local_server import LocalServer
from webterm.config import Config
from webterm.local_server import LocalServer
server = LocalServer("./", Config(), compose_mode=True)
@@ -191,8 +191,8 @@ class TestLocalServerSparklineEndpoint:
@pytest.mark.asyncio
async def test_sparkline_endpoint_returns_svg(self):
"""Sparkline endpoint returns SVG."""
from textual_webterm.config import Config
from textual_webterm.local_server import LocalServer
from webterm.config import Config
from webterm.local_server import LocalServer
server = LocalServer("./", Config(), compose_mode=True)
@@ -206,8 +206,8 @@ class TestLocalServerSparklineEndpoint:
@pytest.mark.asyncio
async def test_sparkline_with_stats_collector(self):
"""Sparkline uses stats collector data when available."""
from textual_webterm.config import Config
from textual_webterm.local_server import LocalServer
from webterm.config import Config
from webterm.local_server import LocalServer
server = LocalServer("./", Config(), compose_mode=True)
server._docker_stats = MagicMock()
+3 -3
View File
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from textual_webterm.docker_watcher import DEFAULT_COMMAND, LABEL_NAME, DockerWatcher
from webterm.docker_watcher import DEFAULT_COMMAND, LABEL_NAME, DockerWatcher
@pytest.fixture
@@ -262,6 +262,6 @@ async def test_watch_events_recovers_from_errors(docker_watcher, monkeypatch):
async def fake_sleep(_seconds):
return None
monkeypatch.setattr("textual_webterm.docker_watcher.asyncio.open_unix_connection", fail_once)
monkeypatch.setattr("textual_webterm.docker_watcher.asyncio.sleep", fake_sleep)
monkeypatch.setattr("webterm.docker_watcher.asyncio.open_unix_connection", fail_once)
monkeypatch.setattr("webterm.docker_watcher.asyncio.sleep", fake_sleep)
await docker_watcher._watch_events()
+4 -4
View File
@@ -5,8 +5,8 @@ import pytest
@pytest.mark.asyncio
async def test_exit_poller_noop_when_idle_wait_zero(monkeypatch):
from textual_webterm import exit_poller
from textual_webterm.exit_poller import ExitPoller
from webterm import exit_poller
from webterm.exit_poller import ExitPoller
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
@@ -32,8 +32,8 @@ async def test_exit_poller_noop_when_idle_wait_zero(monkeypatch):
@pytest.mark.asyncio
async def test_exit_poller_resets_idle_timer_when_session_appears(monkeypatch):
from textual_webterm import exit_poller
from textual_webterm.exit_poller import ExitPoller
from webterm import exit_poller
from webterm.exit_poller import ExitPoller
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
from textual_webterm.config import App, Config
from textual_webterm.local_server import WEBTERM_STATIC_PATH, LocalServer
from webterm.config import App, Config
from webterm.local_server import WEBTERM_STATIC_PATH, LocalServer
class TestLocalServer:
+11 -11
View File
@@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp import web
from textual_webterm.config import App, Config
from textual_webterm.local_server import (
from webterm.config import App, Config
from webterm.local_server import (
LocalServer,
)
@@ -16,20 +16,20 @@ class TestGetStaticPath:
def test_static_path_exists(self):
"""Test that static path exists."""
from textual_webterm.local_server import WEBTERM_STATIC_PATH
from webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists()
def test_static_path_has_js(self):
"""Test that static path has JS directory."""
from textual_webterm.local_server import WEBTERM_STATIC_PATH
from webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js").exists()
def test_static_path_has_wasm(self):
"""Test that static path has WASM file."""
from textual_webterm.local_server import WEBTERM_STATIC_PATH
from webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
@@ -66,8 +66,8 @@ class TestLocalServer:
assert server.session_manager is not None
def test_add_app(self, server):
"""Test adding an app."""
server.add_app("New App", "python app.py", "newapp")
"""Test adding a terminal app."""
server.add_app("New Terminal", "bash", "newapp")
assert "newapp" in server.session_manager.apps_by_slug
def test_add_terminal(self, server):
@@ -79,7 +79,7 @@ class TestLocalServer:
@pytest.mark.asyncio
async def test_create_terminal_session_uses_slug_and_starts_session(self, server, monkeypatch):
from textual_webterm import local_server
from webterm import local_server
monkeypatch.setattr(local_server, "generate", lambda: "fixed-session")
@@ -404,7 +404,7 @@ class TestLocalServerMoreCoverage:
assert server_with_no_apps.exit_event.is_set()
def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch):
from textual_webterm import constants as constants_mod
from webterm import constants as constants_mod
monkeypatch.setattr(constants_mod, "WINDOWS", True)
server_with_no_apps.add_terminal("T", "cmd", "slug")
@@ -542,7 +542,7 @@ class TestLocalServerMoreCoverage:
coro.close()
return MagicMock()
monkeypatch.setattr("textual_webterm.local_server.asyncio.create_task", fake_create_task)
monkeypatch.setattr("webterm.local_server.asyncio.create_task", fake_create_task)
server_with_no_apps.on_keyboard_interrupt()
assert fake_loop.call_soon_threadsafe.called
@@ -556,7 +556,7 @@ class TestLocalServerMoreCoverage:
):
from unittest.mock import MagicMock
from textual_webterm import local_server
from webterm import local_server
# Create a mock path that returns False for exists()
fake_path = MagicMock()
@@ -9,9 +9,9 @@ import pytest
from aiohttp import WSMsgType, web
from aiohttp.test_utils import TestClient, TestServer
from textual_webterm.config import App, Config
from textual_webterm.local_server import LocalServer
from textual_webterm.types import RouteKey, SessionID
from webterm.config import App, Config
from webterm.local_server import LocalServer
from webterm.types import RouteKey, SessionID
if TYPE_CHECKING:
from collections.abc import AsyncIterator
+5 -5
View File
@@ -6,7 +6,7 @@ class TestConstants:
def test_import(self):
"""Test module can be imported."""
from textual_webterm import constants
from webterm import constants
assert constants is not None
@@ -14,7 +14,7 @@ class TestConstants:
"""Test DEBUG constant exists and respects env var."""
import importlib
from textual_webterm import constants
from webterm import constants
assert hasattr(constants, "DEBUG")
assert isinstance(constants.DEBUG, bool)
@@ -33,7 +33,7 @@ class TestExitPoller:
def test_import(self):
"""Test module can be imported."""
from textual_webterm.exit_poller import ExitPoller
from webterm.exit_poller import ExitPoller
assert ExitPoller is not None
@@ -41,8 +41,8 @@ class TestExitPoller:
"""ExitPoller should call force_exit after idle_wait seconds with no sessions."""
import asyncio
from textual_webterm import exit_poller
from textual_webterm.exit_poller import ExitPoller
from webterm import exit_poller
from webterm.exit_poller import ExitPoller
# Speed up the poll loop for the unit test.
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
+1 -1
View File
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from textual_webterm.poller import Poller, Write
from webterm.poller import Poller, Write
class TestWrite:
+4 -4
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import pytest
from textual_webterm.session import Session, SessionConnector
from textual_webterm.types import RouteKey, SessionID
from webterm.session import Session, SessionConnector
from webterm.types import RouteKey, SessionID
class TestSessionConnector:
@@ -68,14 +68,14 @@ class TestIdentity:
def test_generate_unique_ids(self) -> None:
"""Test that generated IDs are unique."""
from textual_webterm.identity import generate
from webterm.identity import generate
ids = [generate() for _ in range(100)]
assert len(set(ids)) == 100 # All unique
def test_generate_id_format(self) -> None:
"""Test that generated IDs have expected format."""
from textual_webterm.identity import generate
from webterm.identity import generate
id_ = generate()
assert isinstance(id_, str)
+4 -23
View File
@@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual_webterm.config import App
from textual_webterm.session_manager import SessionManager
from textual_webterm.types import RouteKey, SessionID
from webterm.config import App
from webterm.session_manager import SessionManager
from webterm.types import RouteKey, SessionID
class TestSessionManager:
@@ -173,7 +173,7 @@ class TestSessionManager:
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
async def test_new_terminal_session(self, mock_poller, mock_path):
"""Test creating a new terminal session."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
app = App(name="Terminal", slug="term", path="./", command="echo test", terminal=True)
manager = SessionManager(mock_poller, mock_path, [app])
@@ -190,25 +190,6 @@ class TestSessionManager:
assert SessionID("test-session") in manager.sessions
assert RouteKey("test-route") in manager.routes
@pytest.mark.asyncio
async def test_new_app_session(self, mock_poller, mock_path):
"""Test creating a new app session."""
from textual_webterm.app_session import AppSession
app = App(name="App", slug="app", path="./", command="python app.py", terminal=False)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(AppSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"app",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, AppSession)
class TestSessionManagerRoutes:
"""Tests for SessionManager route handling."""
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests for slugify module."""
from textual_webterm.slugify import slugify
from webterm.slugify import slugify
class TestSlugify:
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from textual_webterm.svg_exporter import (
from webterm.svg_exporter import (
ANSI_COLORS,
DEFAULT_BG,
DEFAULT_FG,
+66 -57
View File
@@ -21,19 +21,19 @@ class TestTerminalSession:
def test_import(self):
"""Test that module can be imported."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
assert TerminalSession is not None
def test_replay_buffer_size(self):
"""Test replay buffer size constant."""
from textual_webterm.terminal_session import REPLAY_BUFFER_SIZE
from webterm.terminal_session import REPLAY_BUFFER_SIZE
assert REPLAY_BUFFER_SIZE == 256 * 1024 # 64KB
def test_init(self):
"""Test TerminalSession initialization."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -46,17 +46,26 @@ class TestTerminalSession:
def test_init_default_shell(self):
"""Test that default shell is used when command is empty."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
session = TerminalSession(mock_poller, "test-session", "")
assert session.command == "/bin/zsh"
def test_package_version_fallback(self):
from webterm.terminal_session import TerminalSession
with (
patch("webterm.terminal_session.version", side_effect=RuntimeError()),
patch("webterm.terminal_session.PackageNotFoundError", RuntimeError),
):
assert TerminalSession._package_version() == "0.0.0"
@pytest.mark.asyncio
async def test_replay_buffer_add(self):
"""Test adding data to replay buffer."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -68,7 +77,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_replay_buffer_multiple_adds(self):
"""Test adding multiple chunks to replay buffer."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -80,7 +89,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_replay_buffer_overflow(self):
"""Test that replay buffer trims old data when exceeding limit."""
from textual_webterm.terminal_session import (
from webterm.terminal_session import (
REPLAY_BUFFER_SIZE,
TerminalSession,
)
@@ -99,7 +108,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_screen_state_updates_with_data(self):
"""Test that pyte screen updates when data is received."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -114,7 +123,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_screen_handles_cursor_positioning(self):
"""Test that pyte screen correctly handles cursor positioning (tmux-style)."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -133,7 +142,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_get_screen_state_returns_dirty_flag(self):
"""Test that get_screen_state returns has_changes flag based on pyte dirty tracking."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -158,7 +167,7 @@ class TestTerminalSession:
def test_update_connector(self):
"""Test updating connector."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -169,7 +178,7 @@ class TestTerminalSession:
def test_is_running_not_started(self):
"""Test is_running when session not started."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -179,7 +188,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_send_bytes_no_fd(self):
"""Test send_bytes returns False when no master_fd."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -190,7 +199,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_send_meta(self):
"""Test send_meta returns True."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -201,7 +210,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_close_no_pid(self):
"""Test close when no pid."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -212,7 +221,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_wait_no_task(self):
"""Test wait when no task."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -222,7 +231,7 @@ class TestTerminalSession:
def test_repr(self):
"""Test repr output."""
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
@@ -233,7 +242,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_open_uses_shlex_split_and_execvp_with_args(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
command = 'echo "hello world"'
@@ -241,15 +250,15 @@ class TestTerminalSession:
with (
patch(
"textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)
"webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)
) as mock_fork,
patch("textual_webterm.terminal_session.version", return_value="0.0.0"),
patch("textual_webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
patch("webterm.terminal_session.version", return_value="0.0.0"),
patch("webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
patch(
"textual_webterm.terminal_session.os.execvp", side_effect=OSError()
"webterm.terminal_session.os.execvp", side_effect=OSError()
) as mock_execvp,
patch(
"textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
) as mock_exit,
pytest.raises(SystemExit),
):
@@ -262,13 +271,13 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_open_parent_branch_sets_fd_and_pid(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
with (
patch("textual_webterm.terminal_session.pty.fork", return_value=(1234, 99)),
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
patch.object(session, "_set_terminal_size") as set_size,
):
await session.open(width=80, height=24)
@@ -279,16 +288,16 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_open_bad_command_exits(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bad")
with (
patch("textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
patch("textual_webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
patch("webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
patch(
"textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
) as mock_exit,
pytest.raises(SystemExit),
):
@@ -298,7 +307,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_get_screen_lines_strips(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -319,7 +328,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_get_screen_state_no_changes(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -352,7 +361,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_get_screen_state_clears_dirty(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -389,7 +398,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_get_screen_has_changes_reads_dirty(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -414,7 +423,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_send_bytes_handles_closed_fd(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
poller.write = AsyncMock(side_effect=KeyError)
@@ -426,7 +435,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_run_reads_from_poller_and_closes(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
await queue.put(b"hello")
@@ -444,7 +453,7 @@ class TestTerminalSession:
session.master_fd = 10
session._connector = connector
with patch("textual_webterm.terminal_session.os.close") as mock_close:
with patch("webterm.terminal_session.os.close") as mock_close:
await session.run()
connector.on_data.assert_awaited_once_with(b"hello")
@@ -454,7 +463,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_start_updates_connector_when_already_running(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -472,7 +481,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_send_bytes_writes_via_poller(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
poller.write = AsyncMock()
@@ -485,15 +494,15 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_open_set_terminal_size_oserror_closes_fd_and_clears_master_fd(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
with (
patch("textual_webterm.terminal_session.pty.fork", return_value=(1234, 99)),
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
patch.object(session, "_set_terminal_size", side_effect=OSError("bad")),
patch("textual_webterm.terminal_session.os.close") as mock_close,
patch("webterm.terminal_session.os.close") as mock_close,
pytest.raises(OSError),
):
await session.open(width=80, height=24)
@@ -503,7 +512,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_set_terminal_size_uses_executor(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -516,20 +525,20 @@ class TestTerminalSession:
run_in_executor.assert_awaited_once_with(None, session._set_terminal_size, 80, 24)
def test__set_terminal_size_calls_ioctl(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
session.master_fd = 10
with patch("textual_webterm.terminal_session.fcntl.ioctl") as mock_ioctl:
with patch("webterm.terminal_session.fcntl.ioctl") as mock_ioctl:
session._set_terminal_size(80, 24)
assert mock_ioctl.called
@pytest.mark.asyncio
async def test_start_creates_task_when_not_running(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -547,7 +556,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_run_without_connector_still_closes(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
await queue.put(b"hello")
@@ -561,7 +570,7 @@ class TestTerminalSession:
session.master_fd = 10
session._connector = None
with patch("textual_webterm.terminal_session.os.close") as mock_close:
with patch("webterm.terminal_session.os.close") as mock_close:
await session.run()
poller.remove_file.assert_called_once_with(10)
@@ -569,7 +578,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_run_oserror_still_closes(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
queue = MagicMock()
queue.get = AsyncMock(side_effect=OSError("boom"))
@@ -582,7 +591,7 @@ class TestTerminalSession:
session.master_fd = 10
session._connector = None
with patch("textual_webterm.terminal_session.os.close") as mock_close:
with patch("webterm.terminal_session.os.close") as mock_close:
await session.run()
poller.remove_file.assert_called_once_with(10)
@@ -590,26 +599,26 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_close_process_lookup_error_is_ignored(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
session.pid = 123
with patch("textual_webterm.terminal_session.os.kill", side_effect=ProcessLookupError()):
with patch("webterm.terminal_session.os.kill", side_effect=ProcessLookupError()):
await session.close()
@pytest.mark.asyncio
async def test_close_logs_warning_on_unexpected_exception(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
session.pid = 123
with (
patch("textual_webterm.terminal_session.os.kill", side_effect=RuntimeError("x")),
patch("textual_webterm.terminal_session.log.warning") as warn,
patch("webterm.terminal_session.os.kill", side_effect=RuntimeError("x")),
patch("webterm.terminal_session.log.warning") as warn,
):
await session.close()
@@ -617,7 +626,7 @@ class TestTerminalSession:
@pytest.mark.asyncio
async def test_wait_suppresses_cancelled_error(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -629,7 +638,7 @@ class TestTerminalSession:
await session.wait()
def test_is_running_false_when_kill_fails(self):
from textual_webterm.terminal_session import TerminalSession
from webterm.terminal_session import TerminalSession
poller = MagicMock()
session = TerminalSession(poller, "sid", "bash")
@@ -637,5 +646,5 @@ class TestTerminalSession:
session._task = MagicMock()
session.pid = 123
with patch("textual_webterm.terminal_session.os.kill", side_effect=OSError()):
with patch("webterm.terminal_session.os.kill", side_effect=OSError()):
assert session.is_running() is False
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from textual_webterm._two_way_dict import TwoWayDict
from webterm._two_way_dict import TwoWayDict
class TestTwoWayDict:
+1 -1
View File
@@ -12,6 +12,6 @@
"isolatedModules": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/textual_webterm/static/js/**/*.ts"],
"include": ["src/webterm/static/js/**/*.ts"],
"exclude": ["node_modules"]
}