Bump minor version and update ghostty-web
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
# textual-webterm
|
||||
# webterm
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
## 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,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=="],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Generated
+5
-4
@@ -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
@@ -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
@@ -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/*",
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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('"', """)
|
||||
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('"', """)
|
||||
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
@@ -1 +1 @@
|
||||
"""Tests for textual-webterm."""
|
||||
"""Tests for webterm."""
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,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,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
"""Tests for slugify module."""
|
||||
|
||||
from textual_webterm.slugify import slugify
|
||||
from webterm.slugify import slugify
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user