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
|
- runner: ubuntu-latest
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
suffix: amd64
|
suffix: amd64
|
||||||
- runner: ubuntu-24.04-arm
|
- runner: ubuntu-24.04-arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
suffix: arm64
|
suffix: arm64
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -7,6 +7,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
*.pyi
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
@@ -122,14 +123,20 @@ dmypy.json
|
|||||||
# pyright
|
# pyright
|
||||||
.pyright/
|
.pyright/
|
||||||
|
|
||||||
|
# cache / build artifacts
|
||||||
|
.cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
# pdm
|
# pdm
|
||||||
.pdm.toml
|
.pdm.toml
|
||||||
.pdm-python
|
.pdm-python
|
||||||
.pdm-build/
|
.pdm-build/
|
||||||
|
|
||||||
# textual-webterm specific
|
# webterm specific
|
||||||
textual.log
|
webterm.log
|
||||||
|
|
||||||
# Node.js / Bun (for development only)
|
# Node.js / Bun (for development only)
|
||||||
node_modules/
|
node_modules/
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
package-lock.json
|
||||||
|
|||||||
+4
-6
@@ -1,7 +1,7 @@
|
|||||||
# Minimal image for serving a web terminal with Docker watch mode
|
# Minimal image for serving a web terminal with Docker watch mode
|
||||||
#
|
#
|
||||||
# Build: docker build -t textual-webterm .
|
# Build: docker build -t webterm .
|
||||||
# Run: docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 textual-webterm --docker-watch
|
# 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
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
@@ -14,8 +14,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY pyproject.toml poetry.lock* ./
|
COPY pyproject.toml poetry.lock* ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY Makefile ./
|
|
||||||
|
|
||||||
# Install the package
|
# Install the package
|
||||||
RUN pip install --no-cache-dir .
|
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 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/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)
|
# Create non-root user (optional, but may need root for Docker socket access)
|
||||||
# RUN useradd -m webterm
|
# RUN useradd -m webterm
|
||||||
@@ -37,5 +35,5 @@ COPY --from=builder /usr/local/bin/textual-webterm /usr/local/bin/textual-webter
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["textual-webterm"]
|
ENTRYPOINT ["webterm"]
|
||||||
CMD ["--host", "0.0.0.0", "--port", "8080", "--docker-watch"]
|
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
|
- 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
|
PIP ?= $(PYTHON) -m pip
|
||||||
|
|
||||||
# Static assets
|
# 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_JS = $(STATIC_JS_DIR)/terminal.js
|
||||||
TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts
|
TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts
|
||||||
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
|
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
|
||||||
@@ -60,7 +60,7 @@ test:
|
|||||||
pytest
|
pytest
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
pytest --cov=src/textual_webterm --cov-report=term-missing
|
pytest --cov=src/webterm --cov-report=term-missing
|
||||||
|
|
||||||
check: lint coverage
|
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, since it provides an easy way to expose terminal sessions via HTTP/WebSocket with automatic reconnection support:
|
||||||
|
|
||||||
Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to keep track of several containerized AI coding agents:
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🖥️ **Web-based terminal** - Access your terminal from any browser
|
- **Web-based terminal** - Access your terminal from any browser
|
||||||
- 📱 **Mobile support** - Works on iOS Safari and Android with on-screen keyboard
|
- **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
|
||||||
- 🔄 **Session reconnection** - Refresh the page and reconnect to the same session
|
- **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly
|
||||||
- 🎨 **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly
|
- **Customizable themes** - 9 built-in themes (monokai, dracula, nord, etc.)
|
||||||
- 🎭 **Customizable themes** - 9 built-in themes (monokai, dracula, nord, etc.)
|
- **Custom fonts** - Configure terminal font family and size
|
||||||
- 🔤 **Custom fonts** - Configure terminal font family and size
|
- **Scrollback history** - Scroll back through terminal output (configurable)
|
||||||
- 📜 **Scrollback history** - Scroll back through terminal output (configurable)
|
- **Auto-sizing** - Terminal automatically resizes to fit the browser window
|
||||||
- 📐 **Auto-sizing** - Terminal automatically resizes to fit the browser window
|
- **Live screenshots** - Dashboard shows real-time SVG screenshots of terminals
|
||||||
- 📸 **Live screenshots** - Dashboard shows real-time SVG screenshots of terminals
|
- **CPU sparklines** - Dashboard displays 30-minute CPU history for Docker containers
|
||||||
- 📊 **CPU sparklines** - Dashboard displays 30-minute CPU history for Docker containers
|
- **SSE updates** - Real-time screenshot updates via Server-Sent Events
|
||||||
- ⚡ **SSE updates** - Real-time screenshot updates via Server-Sent Events
|
- **Simple CLI** - One command to start serving
|
||||||
- 🚀 **Simple CLI** - One command to start serving
|
|
||||||
|
|
||||||
## Non-Features
|
## Non-Features
|
||||||
|
|
||||||
@@ -37,16 +34,10 @@ Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install from PyPI:
|
Install directly from GitHub:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install textual-webterm
|
pip install git+https://github.com/rcarmo/webterm.git
|
||||||
```
|
|
||||||
|
|
||||||
Or install directly from GitHub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install git+https://github.com/rcarmo/textual-webterm.git
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -56,27 +47,13 @@ pip install git+https://github.com/rcarmo/textual-webterm.git
|
|||||||
Serve your default shell:
|
Serve your default shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
textual-webterm
|
webterm
|
||||||
```
|
```
|
||||||
|
|
||||||
Serve a specific command:
|
Serve a specific command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
textual-webterm htop
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
@@ -84,14 +61,14 @@ textual-webterm --app ./calculator.py:CalculatorApp
|
|||||||
Specify host and port:
|
Specify host and port:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
textual-webterm --host 0.0.0.0 --port 8080 bash
|
webterm --host 0.0.0.0 --port 8080 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Customize theme and font:
|
Customize theme and font:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
textual-webterm --theme dracula --font-size 18
|
webterm --theme dracula --font-size 18
|
||||||
textual-webterm --theme nord --font-family "JetBrains Mono, monospace"
|
webterm --theme nord --font-family "JetBrains Mono, monospace"
|
||||||
```
|
```
|
||||||
|
|
||||||
Available themes: `xterm` (default), `monokai`, `dark`, `light`, `dracula`, `catppuccin`, `nord`, `gruvbox`, `solarized`, `tokyo`.
|
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:
|
Run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
textual-webterm --landing-manifest landing.yaml
|
webterm --landing-manifest landing.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Watch Mode
|
### 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:
|
Watch for Docker containers with the `webterm-command` label and dynamically add/remove terminal sessions:
|
||||||
|
|
||||||
```bash
|
```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:
|
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:
|
Start with:
|
||||||
|
|
||||||
```bash
|
```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`).
|
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
|
## 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)
|
COMMAND: Shell command to run in terminal (default: $SHELL)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-H, --host TEXT Host to bind to [default: 0.0.0.0]
|
-H, --host TEXT Host to bind to [default: 0.0.0.0]
|
||||||
-p, --port INTEGER Port to bind to [default: 8080]
|
-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
|
-L, --landing-manifest PATH YAML manifest describing landing page tiles
|
||||||
(slug/name/command).
|
(slug/name/command).
|
||||||
-C, --compose-manifest PATH Docker compose YAML; services with label
|
-C, --compose-manifest PATH Docker compose YAML; services with label
|
||||||
@@ -218,8 +193,8 @@ Options:
|
|||||||
### Setup (Makefile-first)
|
### Setup (Makefile-first)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/rcarmo/textual-webterm.git
|
git clone https://github.com/rcarmo/webterm.git
|
||||||
cd textual-webterm
|
cd webterm
|
||||||
|
|
||||||
# Install with dev dependencies via Makefile
|
# Install with dev dependencies via Makefile
|
||||||
make install-dev
|
make install-dev
|
||||||
@@ -265,13 +240,14 @@ make bundle-watch
|
|||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`.
|
- 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.
|
- 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).
|
- CPU stats are read directly from Docker socket using asyncio (no additional dependencies).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.9+
|
- Python 3.9+
|
||||||
|
- Bun
|
||||||
- Linux or macOS
|
- Linux or macOS
|
||||||
|
|
||||||
## License
|
## 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](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
|
- [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)
|
- [pyte](https://github.com/selectel/pyte) - PYTE terminal emulator (used for SVG screenshots)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "textual-webterm-frontend",
|
"name": "webterm-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ghostty-web": "^0.1.0",
|
"ghostty-web": "github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
This document describes the internal architecture of textual-webterm.
|
This document describes the internal architecture of webterm.
|
||||||
|
|
||||||
## Overview
|
## 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:
|
Manages the mapping between route keys and sessions:
|
||||||
|
|
||||||
- **TwoWayDict**: Bidirectional mapping of RouteKey ↔ SessionID
|
- **TwoWayDict**: Bidirectional mapping of RouteKey ↔ SessionID
|
||||||
- **Session creation**: Creates TerminalSession or AppSession on demand
|
- **Session creation**: Creates TerminalSession on demand
|
||||||
- **App registry**: Stores app configurations from manifest files
|
- **App registry**: Stores terminal configurations from manifest files
|
||||||
|
|
||||||
### terminal_session.py
|
### terminal_session.py
|
||||||
|
|
||||||
@@ -229,14 +229,13 @@ Unlike traditional web terminals, sessions survive page refreshes:
|
|||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/textual_webterm/
|
src/webterm/
|
||||||
├── cli.py # Click CLI entry point
|
├── cli.py # Click CLI entry point
|
||||||
├── config.py # Configuration parsing (YAML manifests)
|
├── config.py # Configuration parsing (YAML manifests)
|
||||||
├── local_server.py # Main HTTP/WebSocket server
|
├── local_server.py # Main HTTP/WebSocket server
|
||||||
├── session_manager.py # Session registry and routing
|
├── session_manager.py # Session registry and routing
|
||||||
├── session.py # Abstract session interface
|
├── session.py # Abstract session interface
|
||||||
├── terminal_session.py # PTY-based terminal session
|
├── terminal_session.py # PTY-based terminal session
|
||||||
├── app_session.py # Textual app session
|
|
||||||
├── poller.py # Async I/O polling thread
|
├── poller.py # Async I/O polling thread
|
||||||
├── svg_exporter.py # Terminal→SVG renderer
|
├── svg_exporter.py # Terminal→SVG renderer
|
||||||
├── docker_stats.py # Docker CPU metrics collector
|
├── 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:
|
No action required. The pre-built `terminal.js` bundle is committed to the repo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install textual-webterm
|
pip install webterm
|
||||||
# or
|
# 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.
|
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
|
**Goal**: Establish Bun-based build pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
src/textual_webterm/
|
src/webterm/
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ └── terminal.ts # New: our xterm wrapper
|
│ │ └── terminal.ts # New: our xterm wrapper
|
||||||
@@ -204,7 +204,7 @@ src/textual_webterm/
|
|||||||
**package.json** (final):
|
**package.json** (final):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "textual-webterm-frontend",
|
"name": "webterm-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -220,8 +220,8 @@ src/textual_webterm/
|
|||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --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/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --watch --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] Addon initialization (fit, webgl, canvas, unicode11, web-links, clipboard)
|
||||||
- [x] Configurable options via data attributes or window config
|
- [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 ✅
|
### Phase 3: Server Integration ✅
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ bundle-watch: node_modules
|
|||||||
bun run watch
|
bun run watch
|
||||||
|
|
||||||
bundle-clean:
|
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
|
node_modules: package.json
|
||||||
bun install
|
bun install
|
||||||
@@ -314,7 +314,7 @@ ENV PATH="/root/.bun/bin:${PATH}"
|
|||||||
# Build frontend
|
# Build frontend
|
||||||
COPY package.json bunfig.toml ./
|
COPY package.json bunfig.toml ./
|
||||||
RUN bun install
|
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
|
RUN bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -377,11 +377,11 @@ RUN bun run build
|
|||||||
|
|
||||||
# Future: Go Reimplementation
|
# 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
|
## 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
|
## Executive Summary
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ Not yet started. This would be a separate project (`textual-webterm-go`) providi
|
|||||||
|
|
||||||
## pyte vs GoPyte: Thorough Comparison
|
## 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)
|
### 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.
|
**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:
|
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).
|
- [ ] Resize correctness (content preservation, cursor placement).
|
||||||
- [ ] Performance at high output rates (100k+ lines, low latency).
|
- [ ] 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).
|
- **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.
|
- **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 |
|
| Loss | Impact |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| **Textual app support** | Cannot run Python Textual apps directly |
|
| **Textual app support** | Removed in webterm |
|
||||||
| **Rapid prototyping** | Go requires more boilerplate |
|
| **Rapid prototyping** | Go requires more boilerplate |
|
||||||
| **pyte maturity** | GoPyte is less proven |
|
| **pyte maturity** | GoPyte is less proven |
|
||||||
|
|
||||||
@@ -556,7 +556,7 @@ These alternatives still need capture parity validation.
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
// go.mod
|
// go.mod
|
||||||
module github.com/rcarmo/textual-webterm-go
|
module github.com/rcarmo/webterm-go
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
@@ -771,7 +771,7 @@ README.md # Usage docs
|
|||||||
## File Structure (Final)
|
## File Structure (Final)
|
||||||
|
|
||||||
```
|
```
|
||||||
textual-webterm-go/
|
webterm-go/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── webterm/
|
│ └── webterm/
|
||||||
│ └── main.go
|
│ └── main.go
|
||||||
@@ -867,7 +867,7 @@ ENTRYPOINT ["/webterm"]
|
|||||||
Proceed with Go reimplementation if:
|
Proceed with Go reimplementation if:
|
||||||
|
|
||||||
- [ ] Deployment size is critical (embedded, edge, IoT)
|
- [ ] Deployment size is critical (embedded, edge, IoT)
|
||||||
- [ ] No need for Textual app support
|
- [x] No need for Textual app support
|
||||||
- [ ] Want single-binary distribution
|
- [ ] Want single-binary distribution
|
||||||
- [ ] Memory constraints matter
|
- [ ] Memory constraints matter
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ class OpenLink(App[None]):
|
|||||||
"""Demonstrates opening a URL in the same tab or a new tab."""
|
"""Demonstrates opening a URL in the same tab or a new tab."""
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Button("Visit the Textual docs", id="open-link-same-tab")
|
yield Button("Visit the terminal 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 in a new tab", id="open-link-new-tab")
|
||||||
|
|
||||||
@on(Button.Pressed)
|
@on(Button.Pressed)
|
||||||
def open_link(self, event: Button.Pressed) -> None:
|
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."""
|
"""Open the URL in the same tab or a new tab depending on which button was pressed."""
|
||||||
self.open_url(
|
self.open_url(
|
||||||
"https://textual.textualize.io",
|
"https://example.com",
|
||||||
new_tab=event.button.id == "open-link-new-tab",
|
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,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "textual-webterm-frontend",
|
"name": "webterm-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ghostty-web": "github:rcarmo/ghostty-web"
|
"ghostty-web": "github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/ghostty-web": {
|
"node_modules/ghostty-web": {
|
||||||
"version": "0.4.0-ime-fix",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
|
|||||||
+6
-6
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "textual-webterm-frontend",
|
"name": "webterm-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ghostty-web": "github:rcarmo/ghostty-web"
|
"ghostty-web": "github:rcarmo/ghostty-web#2837b81646aa0c00bb5a2881b5a53400346c76de"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": "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/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser",
|
"build:fast": "bun build src/webterm/static/js/terminal.ts --outfile=src/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",
|
"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",
|
"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]
|
[tool.poetry]
|
||||||
name = "textual-webterm"
|
name = "webterm"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
description = "Serve terminal sessions over the web"
|
description = "Serve terminal sessions over the web"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [{include = "textual_webterm", from = "src"}]
|
packages = [{include = "webterm", from = "src"}]
|
||||||
include = [
|
include = [
|
||||||
{ path = "src/textual_webterm/static/monospace.css" },
|
{ path = "src/webterm/static/monospace.css" },
|
||||||
{ path = "src/textual_webterm/static/js/terminal.js" },
|
{ path = "src/webterm/static/js/terminal.js" },
|
||||||
{ path = "src/textual_webterm/static/js/ghostty-vt.wasm" },
|
{ path = "src/webterm/static/js/ghostty-vt.wasm" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
@@ -35,7 +35,7 @@ requires = ["poetry-core"]
|
|||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
textual-webterm = "textual_webterm.cli:app"
|
webterm = "webterm.cli:app"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
@@ -71,7 +71,7 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["textual_webterm"]
|
known-first-party = ["webterm"]
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
@@ -95,7 +95,7 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["src/textual_webterm"]
|
source = ["src/webterm"]
|
||||||
branch = true
|
branch = true
|
||||||
omit = [
|
omit = [
|
||||||
"*/tests/*",
|
"*/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."""
|
"""Root configuration model."""
|
||||||
|
|
||||||
apps: list[App] = Field(default_factory=list)
|
apps: list[App] = Field(default_factory=list)
|
||||||
landing: list[App] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def default_config() -> Config:
|
def default_config() -> Config:
|
||||||
@@ -65,11 +64,12 @@ def load_config(config_path: Path) -> Config:
|
|||||||
|
|
||||||
return App(**data)
|
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 += [
|
apps = [make_app(name, app, terminal=True) for name, app in terminal_entries.items()]
|
||||||
make_app(name, app, terminal=True) for name, app in config_data.get("terminal", {}).items()
|
|
||||||
]
|
|
||||||
|
|
||||||
config = Config(apps=apps)
|
config = Config(apps=apps)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import socket
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
log = logging.getLogger("textual-webterm")
|
log = logging.getLogger("webterm")
|
||||||
|
|
||||||
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
|
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ from .docker_stats import get_docker_socket_path
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .session_manager import SessionManager
|
from .session_manager import SessionManager
|
||||||
|
|
||||||
log = logging.getLogger("textual-webterm")
|
log = logging.getLogger("webterm")
|
||||||
|
|
||||||
LABEL_NAME = "webterm-command"
|
LABEL_NAME = "webterm-command"
|
||||||
DEFAULT_COMMAND = "/bin/bash"
|
DEFAULT_COMMAND = "/bin/bash"
|
||||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
EXIT_POLL_RATE = 5
|
EXIT_POLL_RATE = 5
|
||||||
|
|
||||||
log = logging.getLogger("textual-web")
|
log = logging.getLogger("webterm")
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .local_server import LocalServer
|
from .local_server import LocalServer
|
||||||
@@ -27,7 +27,7 @@ from .types import Meta, RouteKey, SessionID
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
log = logging.getLogger("textual-web")
|
log = logging.getLogger("webterm")
|
||||||
|
|
||||||
DEFAULT_TERMINAL_SIZE = (132, 45)
|
DEFAULT_TERMINAL_SIZE = (132, 45)
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class LocalServer:
|
|||||||
return 5.0
|
return 5.0
|
||||||
return SCREENSHOT_MAX_CACHE_SECONDS
|
return SCREENSHOT_MAX_CACHE_SECONDS
|
||||||
|
|
||||||
"""Manages local Textual apps and terminals without Ganglion server."""
|
"""Manages local terminal sessions without Ganglion server."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -194,7 +194,7 @@ class LocalServer:
|
|||||||
|
|
||||||
def add_terminal(self, name: str, command: str, slug: str = "") -> None:
|
def add_terminal(self, name: str, command: str, slug: str = "") -> None:
|
||||||
if constants.WINDOWS:
|
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
|
return
|
||||||
slug = slug or generate().lower()
|
slug = slug or generate().lower()
|
||||||
self.session_manager.add_app(name, command, slug=slug, terminal=True)
|
self.session_manager.add_app(name, command, slug=slug, terminal=True)
|
||||||
@@ -571,7 +571,7 @@ class LocalServer:
|
|||||||
screen_buffer,
|
screen_buffer,
|
||||||
width=screen_width,
|
width=screen_width,
|
||||||
height=screen_height,
|
height=screen_height,
|
||||||
title="textual-webterm",
|
title="webterm",
|
||||||
)
|
)
|
||||||
|
|
||||||
svg = await asyncio.to_thread(_render_svg)
|
svg = await asyncio.to_thread(_render_svg)
|
||||||
@@ -944,11 +944,11 @@ class LocalServer:
|
|||||||
html_content = """<!DOCTYPE html>
|
html_content = """<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Textual Web Terminal Server</title>
|
<title>Webterm Server</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>No Apps Available</h2>
|
<h2>No Apps Available</h2>
|
||||||
<p>No terminal or Textual applications are configured.</p>
|
<p>No terminal applications are configured.</p>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
return web.Response(text=html_content, content_type="text/html")
|
return web.Response(text=html_content, content_type="text/html")
|
||||||
@@ -965,13 +965,16 @@ class LocalServer:
|
|||||||
route_key = RouteKey(generate().lower())
|
route_key = RouteKey(generate().lower())
|
||||||
|
|
||||||
ws_url = self._get_ws_url_from_request(request, route_key)
|
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
|
# 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}"'
|
data_attrs = (
|
||||||
if self.font_family:
|
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
|
# Escape quotes for HTML attribute
|
||||||
escaped_font = self.font_family.replace('"', """)
|
escaped_font = font_family.replace('"', """)
|
||||||
data_attrs += f' data-font-family="{escaped_font}"'
|
data_attrs += f' data-font-family="{escaped_font}"'
|
||||||
|
|
||||||
# Get theme background color (fallback to black if unknown theme)
|
# Get theme background color (fallback to black if unknown theme)
|
||||||
@@ -985,11 +988,11 @@ class LocalServer:
|
|||||||
<style>
|
<style>
|
||||||
html, body {{ width: 100%; height: 100%; }}
|
html, body {{ width: 100%; height: 100%; }}
|
||||||
body {{ background: {theme_bg}; margin: 0; padding: 0; overflow: hidden; }}
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script type=\"module\" src=\"/static/js/terminal.js\"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from . import config, constants
|
from . import config, constants
|
||||||
from ._two_way_dict import TwoWayDict
|
from ._two_way_dict import TwoWayDict
|
||||||
from .app_session import AppSession
|
|
||||||
from .identity import generate
|
from .identity import generate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -18,7 +17,7 @@ if TYPE_CHECKING:
|
|||||||
from .types import RouteKey, SessionID
|
from .types import RouteKey, SessionID
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger("textual-web")
|
log = logging.getLogger("webterm")
|
||||||
|
|
||||||
|
|
||||||
if not constants.WINDOWS:
|
if not constants.WINDOWS:
|
||||||
@@ -26,7 +25,7 @@ if not constants.WINDOWS:
|
|||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Manage sessions (Textual apps or terminals)."""
|
"""Manage terminal sessions."""
|
||||||
|
|
||||||
def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None:
|
def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None:
|
||||||
self.poller = poller
|
self.poller = poller
|
||||||
@@ -122,24 +121,16 @@ class SessionManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
session_process: Session
|
session_process: Session
|
||||||
if app.terminal:
|
|
||||||
if constants.WINDOWS:
|
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 None
|
return None
|
||||||
else:
|
|
||||||
session_process = TerminalSession(
|
session_process = TerminalSession(
|
||||||
self.poller,
|
self.poller,
|
||||||
session_id,
|
session_id,
|
||||||
app.command,
|
app.command,
|
||||||
)
|
)
|
||||||
log.info("Created terminal session %s", session_id)
|
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)
|
|
||||||
|
|
||||||
# Open the session BEFORE registering it, so it's fully initialized
|
# Open the session BEFORE registering it, so it's fully initialized
|
||||||
# when other code can access it via sessions/routes dicts
|
# 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:
|
* Implements the WebSocket protocol compatible with local_server.py:
|
||||||
* - Client → Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
|
* - Client → Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
|
||||||
@@ -409,10 +409,12 @@ class WebTerminal {
|
|||||||
// Build terminal options
|
// Build terminal options
|
||||||
const themeToUse = config.theme ?? THEMES.xterm;
|
const themeToUse = config.theme ?? THEMES.xterm;
|
||||||
console.log("[webterm:create] Theme to use (config.theme ?? THEMES.xterm):", JSON.stringify(themeToUse, null, 2));
|
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 = {
|
const options: ITerminalOptions = {
|
||||||
fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY,
|
fontFamily,
|
||||||
fontSize: config.fontSize ?? 16,
|
fontSize,
|
||||||
scrollback: config.scrollback ?? 1000,
|
scrollback: config.scrollback ?? 1000,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "block",
|
cursorStyle: "block",
|
||||||
@@ -456,8 +458,8 @@ class WebTerminal {
|
|||||||
wsUrl,
|
wsUrl,
|
||||||
terminal,
|
terminal,
|
||||||
fitAddon,
|
fitAddon,
|
||||||
options.fontFamily ?? DEFAULT_FONT_FAMILY,
|
fontFamily,
|
||||||
options.fontSize ?? 16
|
fontSize
|
||||||
);
|
);
|
||||||
console.log("[webterm:create] WebTerminal instance created");
|
console.log("[webterm:create] WebTerminal instance created");
|
||||||
instance.initialize();
|
instance.initialize();
|
||||||
@@ -488,7 +490,11 @@ class WebTerminal {
|
|||||||
|
|
||||||
// Wait for fonts to load before fitting to ensure correct measurements
|
// Wait for fonts to load before fitting to ensure correct measurements
|
||||||
this.waitForFonts().then(() => {
|
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();
|
this.fit();
|
||||||
console.log("[webterm:init] fit() completed");
|
console.log("[webterm:init] fit() completed");
|
||||||
|
|
||||||
@@ -1151,8 +1157,8 @@ const instances: Map<HTMLElement, WebTerminal> = new Map();
|
|||||||
/** Initialize all terminal containers on page load */
|
/** Initialize all terminal containers on page load */
|
||||||
async function initTerminals(): Promise<void> {
|
async function initTerminals(): Promise<void> {
|
||||||
console.log("[webterm:init] initTerminals() called");
|
console.log("[webterm:init] initTerminals() called");
|
||||||
const containers = document.querySelectorAll<HTMLElement>(".textual-terminal");
|
const containers = document.querySelectorAll<HTMLElement>(".webterm-terminal");
|
||||||
console.log(`[webterm:init] Found ${containers.length} .textual-terminal containers`);
|
console.log(`[webterm:init] Found ${containers.length} .webterm-terminal containers`);
|
||||||
|
|
||||||
for (const el of containers) {
|
for (const el of containers) {
|
||||||
console.log("[webterm:init] Processing container:", el);
|
console.log("[webterm:init] Processing container:", el);
|
||||||
@@ -5,7 +5,7 @@ We avoid external font fetching (e.g. Google Fonts) to keep local server self-co
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
: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,
|
"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas,
|
||||||
"Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;
|
"Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;
|
||||||
--terminal-min-width: 10px;
|
--terminal-min-width: 10px;
|
||||||
@@ -19,7 +19,7 @@ html, body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: var(--textual-webterm-mono);
|
font-family: var(--webterm-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent scrollbar gutter space reservation */
|
/* Prevent scrollbar gutter space reservation */
|
||||||
@@ -29,7 +29,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Terminal container - works with ghostty-web canvas renderer */
|
/* Terminal container - works with ghostty-web canvas renderer */
|
||||||
.textual-terminal {
|
.webterm-terminal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -42,7 +42,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ghostty-web renders to a canvas element */
|
/* ghostty-web renders to a canvas element */
|
||||||
.textual-terminal canvas {
|
.webterm-terminal canvas {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -52,7 +52,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden textarea for keyboard input */
|
/* Hidden textarea for keyboard input */
|
||||||
.textual-terminal textarea {
|
.webterm-terminal textarea {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -60,14 +60,14 @@ html {
|
|||||||
|
|
||||||
/* High DPI display handling */
|
/* High DPI display handling */
|
||||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||||
.textual-terminal {
|
.webterm-terminal {
|
||||||
/* ghostty-web handles DPI scaling via devicePixelRatio */
|
/* ghostty-web handles DPI scaling via devicePixelRatio */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback for older browsers */
|
/* Fallback for older browsers */
|
||||||
@supports not (display: flex) {
|
@supports not (display: flex) {
|
||||||
.textual-terminal {
|
.webterm-terminal {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -14,7 +14,7 @@ from collections import deque
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pyte
|
import pyte
|
||||||
from importlib_metadata import version
|
from importlib_metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
from .session import Session, SessionConnector
|
from .session import Session, SessionConnector
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
|||||||
from .poller import Poller
|
from .poller import Poller
|
||||||
from .types import Meta, SessionID
|
from .types import Meta, SessionID
|
||||||
|
|
||||||
log = logging.getLogger("textual-web")
|
log = logging.getLogger("webterm")
|
||||||
|
|
||||||
# Maximum bytes to keep in replay buffer for reconnection
|
# Maximum bytes to keep in replay buffer for reconnection
|
||||||
REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB
|
REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB
|
||||||
@@ -62,6 +62,13 @@ class TerminalSession(Session):
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"TerminalSession(session_id={self.session_id!r}, command={self.command!r})"
|
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:
|
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)
|
log.info("Opening terminal session %s with command: %s", self.session_id, self.command)
|
||||||
# Track the initial size
|
# Track the initial size
|
||||||
@@ -76,8 +83,8 @@ class TerminalSession(Session):
|
|||||||
self.pid = pid
|
self.pid = pid
|
||||||
self.master_fd = master_fd
|
self.master_fd = master_fd
|
||||||
if pid == pty.CHILD:
|
if pid == pty.CHILD:
|
||||||
os.environ["TERM_PROGRAM"] = "textual-webterm"
|
os.environ["TERM_PROGRAM"] = "webterm"
|
||||||
os.environ["TERM_PROGRAM_VERSION"] = version("textual-webterm")
|
os.environ["TERM_PROGRAM_VERSION"] = self._package_version()
|
||||||
try:
|
try:
|
||||||
argv = shlex.split(self.command)
|
argv = shlex.split(self.command)
|
||||||
except ValueError:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,10 +8,10 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
from webterm.config import App, Config
|
||||||
from textual_webterm.local_server import LocalServer
|
from webterm.local_server import LocalServer
|
||||||
from textual_webterm.poller import Poller
|
from webterm.poller import Poller
|
||||||
from textual_webterm.session_manager import SessionManager
|
from webterm.session_manager import SessionManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncGenerator, Generator
|
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."""
|
"""Tests for CLI module."""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from webterm import cli
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCLI:
|
class TestCLI:
|
||||||
@@ -87,7 +10,7 @@ class TestCLI:
|
|||||||
|
|
||||||
def test_cli_help(self):
|
def test_cli_help(self):
|
||||||
"""Test CLI help output."""
|
"""Test CLI help output."""
|
||||||
from textual_webterm.cli import app as cli_app
|
cli_app = cli.app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli_app, ["--help"])
|
result = runner.invoke(cli_app, ["--help"])
|
||||||
@@ -95,8 +18,6 @@ class TestCLI:
|
|||||||
assert "terminal" in result.output.lower() or "command" in result.output.lower()
|
assert "terminal" in result.output.lower() or "command" in result.output.lower()
|
||||||
|
|
||||||
def test_cli_runs_terminal_command(self, monkeypatch):
|
def test_cli_runs_terminal_command(self, monkeypatch):
|
||||||
from textual_webterm import cli
|
|
||||||
|
|
||||||
calls: dict[str, object] = {}
|
calls: dict[str, object] = {}
|
||||||
|
|
||||||
class FakeServer:
|
class FakeServer:
|
||||||
@@ -119,9 +40,6 @@ class TestCLI:
|
|||||||
|
|
||||||
def test_cli_runs_default_shell(self, monkeypatch):
|
def test_cli_runs_default_shell(self, monkeypatch):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from textual_webterm import cli
|
|
||||||
|
|
||||||
calls: dict[str, object] = {}
|
calls: dict[str, object] = {}
|
||||||
|
|
||||||
class FakeServer:
|
class FakeServer:
|
||||||
@@ -143,33 +61,18 @@ class TestCLI:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert calls["terminal"][1] == os.environ["SHELL"]
|
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):
|
def test_cli_version(self):
|
||||||
"""Test CLI version output."""
|
"""Test CLI version output."""
|
||||||
from textual_webterm.cli import app as cli_app
|
cli_app = cli.app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli_app, ["--version"])
|
result = runner.invoke(cli_app, ["--version"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "version" in result.output
|
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):
|
def test_cli_port_option(self):
|
||||||
"""Test CLI port option parsing."""
|
"""Test CLI port option parsing."""
|
||||||
from textual_webterm.cli import app as cli_app
|
cli_app = cli.app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli_app, ["--help"])
|
result = runner.invoke(cli_app, ["--help"])
|
||||||
@@ -177,51 +80,83 @@ class TestCLI:
|
|||||||
|
|
||||||
def test_cli_host_option(self):
|
def test_cli_host_option(self):
|
||||||
"""Test CLI host option parsing."""
|
"""Test CLI host option parsing."""
|
||||||
from textual_webterm.cli import app as cli_app
|
cli_app = cli.app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli_app, ["--help"])
|
result = runner.invoke(cli_app, ["--help"])
|
||||||
assert "--host" in result.output or "-H" in result.output
|
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:
|
class TestCLIOptions:
|
||||||
"""Tests for CLI option handling."""
|
"""Tests for CLI option handling."""
|
||||||
|
|
||||||
def test_debug_option(self):
|
def test_debug_option(self):
|
||||||
"""Test --debug option exists."""
|
"""Test --debug option exists."""
|
||||||
from textual_webterm.cli import app as cli_app
|
cli_app = cli.app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli_app, ["--help"])
|
result = runner.invoke(cli_app, ["--help"])
|
||||||
assert "--app" in result.output
|
assert "--docker-watch" in result.output
|
||||||
|
|
||||||
def test_no_run_option(self):
|
def test_no_run_option(self):
|
||||||
"""Test --no-run option exists."""
|
"""Test --no-run option exists."""
|
||||||
from textual_webterm.cli import app as cli_app
|
cli_app = cli.app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli_app, ["--help"])
|
result = runner.invoke(cli_app, ["--help"])
|
||||||
# Check that basic options are documented
|
# Check that basic options are documented
|
||||||
assert "port" in result.output.lower()
|
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 click.testing import CliRunner
|
||||||
|
|
||||||
|
from webterm import cli
|
||||||
|
|
||||||
|
|
||||||
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
|
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
|
||||||
from textual_webterm import cli
|
|
||||||
|
|
||||||
manifest = tmp_path / "landing.yaml"
|
manifest = tmp_path / "landing.yaml"
|
||||||
manifest.write_text(
|
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):
|
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
|
||||||
from textual_webterm import cli
|
|
||||||
|
|
||||||
manifest = tmp_path / "compose.yaml"
|
manifest = tmp_path / "compose.yaml"
|
||||||
manifest.write_text(
|
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 __future__ import annotations
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
import pytest
|
||||||
|
|
||||||
|
from webterm.config import App, Config
|
||||||
|
|
||||||
|
|
||||||
class TestApp:
|
class TestApp:
|
||||||
@@ -21,13 +23,12 @@ class TestApp:
|
|||||||
assert app.terminal is True
|
assert app.terminal is True
|
||||||
assert app.command == "bash"
|
assert app.command == "bash"
|
||||||
|
|
||||||
def test_create_textual_app(self) -> None:
|
def test_create_terminal_app_defaults(self) -> None:
|
||||||
"""Test creating a Textual app configuration."""
|
"""Test creating a terminal app configuration with defaults."""
|
||||||
app = App(
|
app = App(
|
||||||
name="My App",
|
name="My App",
|
||||||
slug="my-app",
|
slug="my-app",
|
||||||
terminal=False,
|
command="bash",
|
||||||
command="python -m myapp",
|
|
||||||
)
|
)
|
||||||
assert app.terminal is False
|
assert app.terminal is False
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class TestDefaultConfig:
|
|||||||
|
|
||||||
def test_default_config_returns_config(self):
|
def test_default_config_returns_config(self):
|
||||||
"""Test that default_config returns a Config object."""
|
"""Test that default_config returns a Config object."""
|
||||||
from textual_webterm.config import default_config
|
from webterm.config import default_config
|
||||||
|
|
||||||
config = default_config()
|
config = default_config()
|
||||||
assert config is not None
|
assert config is not None
|
||||||
@@ -63,27 +64,24 @@ class TestDefaultConfig:
|
|||||||
class TestLoadConfig:
|
class TestLoadConfig:
|
||||||
"""Tests for load_config function."""
|
"""Tests for load_config function."""
|
||||||
|
|
||||||
def test_load_config_parses_app_and_terminal(self, tmp_path):
|
def test_load_config_parses_terminal_only(self, tmp_path):
|
||||||
from textual_webterm.config import load_config
|
from webterm.config import load_config
|
||||||
|
|
||||||
config_path = tmp_path / "config.toml"
|
config_path = tmp_path / "config.toml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
"""
|
"""
|
||||||
[app.demo]
|
|
||||||
command = "echo demo"
|
|
||||||
|
|
||||||
[terminal.shell]
|
[terminal.shell]
|
||||||
command = "bash"
|
command = "bash"
|
||||||
""".lstrip()
|
""".lstrip()
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
assert len(config.apps) == 2
|
assert len(config.apps) == 1
|
||||||
assert {a.name for a in config.apps} == {"demo", "shell"}
|
assert {a.name for a in config.apps} == {"shell"}
|
||||||
assert any(a.terminal for a in config.apps)
|
assert any(a.terminal for a in config.apps)
|
||||||
|
|
||||||
def test_load_config_slugify_for_app(self, tmp_path):
|
def test_load_config_rejects_app_entries(self, tmp_path):
|
||||||
from textual_webterm.config import load_config
|
from webterm.config import load_config
|
||||||
|
|
||||||
config_path = tmp_path / "config.toml"
|
config_path = tmp_path / "config.toml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -92,11 +90,11 @@ command = "bash"
|
|||||||
command = "echo hi"
|
command = "echo hi"
|
||||||
""".lstrip()
|
""".lstrip()
|
||||||
)
|
)
|
||||||
config = load_config(config_path)
|
with pytest.raises(ValueError):
|
||||||
assert config.apps[0].slug
|
load_config(config_path)
|
||||||
|
|
||||||
def test_load_config_expands_vars(self, tmp_path, monkeypatch):
|
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")
|
monkeypatch.setenv("MY_CMD", "echo expanded")
|
||||||
config_path = tmp_path / "config.toml"
|
config_path = tmp_path / "config.toml"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
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():
|
def test_load_landing_yaml_simple():
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
def test_get_environ_bool(monkeypatch):
|
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")
|
monkeypatch.setenv("FLAG", "1")
|
||||||
assert get_environ_bool("FLAG") is True
|
assert get_environ_bool("FLAG") is True
|
||||||
@@ -14,21 +14,21 @@ def test_get_environ_bool(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_get_environ_int_keyerror(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)
|
monkeypatch.delenv("INT", raising=False)
|
||||||
assert get_environ_int("INT", 7) == 7
|
assert get_environ_int("INT", 7) == 7
|
||||||
|
|
||||||
|
|
||||||
def test_get_environ_int_valueerror(monkeypatch):
|
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")
|
monkeypatch.setenv("INT", "not-an-int")
|
||||||
assert get_environ_int("INT", 7) == 7
|
assert get_environ_int("INT", 7) == 7
|
||||||
|
|
||||||
|
|
||||||
def test_get_environ_int_valid(monkeypatch):
|
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")
|
monkeypatch.setenv("INT", "42")
|
||||||
assert get_environ_int("INT", 7) == 42
|
assert get_environ_int("INT", 7) == 42
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm.docker_stats import (
|
from webterm.docker_stats import (
|
||||||
STATS_HISTORY_SIZE,
|
STATS_HISTORY_SIZE,
|
||||||
DockerStatsCollector,
|
DockerStatsCollector,
|
||||||
render_sparkline_svg,
|
render_sparkline_svg,
|
||||||
@@ -177,8 +177,8 @@ class TestLocalServerSparklineEndpoint:
|
|||||||
"""Missing container param returns 400."""
|
"""Missing container param returns 400."""
|
||||||
from aiohttp.web import HTTPBadRequest
|
from aiohttp.web import HTTPBadRequest
|
||||||
|
|
||||||
from textual_webterm.config import Config
|
from webterm.config import Config
|
||||||
from textual_webterm.local_server import LocalServer
|
from webterm.local_server import LocalServer
|
||||||
|
|
||||||
server = LocalServer("./", Config(), compose_mode=True)
|
server = LocalServer("./", Config(), compose_mode=True)
|
||||||
|
|
||||||
@@ -191,8 +191,8 @@ class TestLocalServerSparklineEndpoint:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sparkline_endpoint_returns_svg(self):
|
async def test_sparkline_endpoint_returns_svg(self):
|
||||||
"""Sparkline endpoint returns SVG."""
|
"""Sparkline endpoint returns SVG."""
|
||||||
from textual_webterm.config import Config
|
from webterm.config import Config
|
||||||
from textual_webterm.local_server import LocalServer
|
from webterm.local_server import LocalServer
|
||||||
|
|
||||||
server = LocalServer("./", Config(), compose_mode=True)
|
server = LocalServer("./", Config(), compose_mode=True)
|
||||||
|
|
||||||
@@ -206,8 +206,8 @@ class TestLocalServerSparklineEndpoint:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sparkline_with_stats_collector(self):
|
async def test_sparkline_with_stats_collector(self):
|
||||||
"""Sparkline uses stats collector data when available."""
|
"""Sparkline uses stats collector data when available."""
|
||||||
from textual_webterm.config import Config
|
from webterm.config import Config
|
||||||
from textual_webterm.local_server import LocalServer
|
from webterm.local_server import LocalServer
|
||||||
|
|
||||||
server = LocalServer("./", Config(), compose_mode=True)
|
server = LocalServer("./", Config(), compose_mode=True)
|
||||||
server._docker_stats = MagicMock()
|
server._docker_stats = MagicMock()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
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
|
@pytest.fixture
|
||||||
@@ -262,6 +262,6 @@ async def test_watch_events_recovers_from_errors(docker_watcher, monkeypatch):
|
|||||||
async def fake_sleep(_seconds):
|
async def fake_sleep(_seconds):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("textual_webterm.docker_watcher.asyncio.open_unix_connection", fail_once)
|
monkeypatch.setattr("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.sleep", fake_sleep)
|
||||||
await docker_watcher._watch_events()
|
await docker_watcher._watch_events()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import pytest
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_exit_poller_noop_when_idle_wait_zero(monkeypatch):
|
async def test_exit_poller_noop_when_idle_wait_zero(monkeypatch):
|
||||||
from textual_webterm import exit_poller
|
from webterm import exit_poller
|
||||||
from textual_webterm.exit_poller import ExitPoller
|
from webterm.exit_poller import ExitPoller
|
||||||
|
|
||||||
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_exit_poller_resets_idle_timer_when_session_appears(monkeypatch):
|
async def test_exit_poller_resets_idle_timer_when_session_appears(monkeypatch):
|
||||||
from textual_webterm import exit_poller
|
from webterm import exit_poller
|
||||||
from textual_webterm.exit_poller import ExitPoller
|
from webterm.exit_poller import ExitPoller
|
||||||
|
|
||||||
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
|
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
from webterm.config import App, Config
|
||||||
from textual_webterm.local_server import WEBTERM_STATIC_PATH, LocalServer
|
from webterm.local_server import WEBTERM_STATIC_PATH, LocalServer
|
||||||
|
|
||||||
|
|
||||||
class TestLocalServer:
|
class TestLocalServer:
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
from webterm.config import App, Config
|
||||||
from textual_webterm.local_server import (
|
from webterm.local_server import (
|
||||||
LocalServer,
|
LocalServer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,20 +16,20 @@ class TestGetStaticPath:
|
|||||||
|
|
||||||
def test_static_path_exists(self):
|
def test_static_path_exists(self):
|
||||||
"""Test that static path exists."""
|
"""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()
|
assert WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists()
|
||||||
|
|
||||||
def test_static_path_has_js(self):
|
def test_static_path_has_js(self):
|
||||||
"""Test that static path has JS directory."""
|
"""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 is not None
|
||||||
assert (WEBTERM_STATIC_PATH / "js").exists()
|
assert (WEBTERM_STATIC_PATH / "js").exists()
|
||||||
|
|
||||||
def test_static_path_has_wasm(self):
|
def test_static_path_has_wasm(self):
|
||||||
"""Test that static path has WASM file."""
|
"""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 is not None
|
||||||
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
|
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
|
||||||
@@ -66,8 +66,8 @@ class TestLocalServer:
|
|||||||
assert server.session_manager is not None
|
assert server.session_manager is not None
|
||||||
|
|
||||||
def test_add_app(self, server):
|
def test_add_app(self, server):
|
||||||
"""Test adding an app."""
|
"""Test adding a terminal app."""
|
||||||
server.add_app("New App", "python app.py", "newapp")
|
server.add_app("New Terminal", "bash", "newapp")
|
||||||
assert "newapp" in server.session_manager.apps_by_slug
|
assert "newapp" in server.session_manager.apps_by_slug
|
||||||
|
|
||||||
def test_add_terminal(self, server):
|
def test_add_terminal(self, server):
|
||||||
@@ -79,7 +79,7 @@ class TestLocalServer:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_terminal_session_uses_slug_and_starts_session(self, server, monkeypatch):
|
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")
|
monkeypatch.setattr(local_server, "generate", lambda: "fixed-session")
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
assert server_with_no_apps.exit_event.is_set()
|
assert server_with_no_apps.exit_event.is_set()
|
||||||
|
|
||||||
def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch):
|
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)
|
monkeypatch.setattr(constants_mod, "WINDOWS", True)
|
||||||
server_with_no_apps.add_terminal("T", "cmd", "slug")
|
server_with_no_apps.add_terminal("T", "cmd", "slug")
|
||||||
@@ -542,7 +542,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
coro.close()
|
coro.close()
|
||||||
return MagicMock()
|
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()
|
server_with_no_apps.on_keyboard_interrupt()
|
||||||
assert fake_loop.call_soon_threadsafe.called
|
assert fake_loop.call_soon_threadsafe.called
|
||||||
@@ -556,7 +556,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
):
|
):
|
||||||
from unittest.mock import MagicMock
|
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()
|
# Create a mock path that returns False for exists()
|
||||||
fake_path = MagicMock()
|
fake_path = MagicMock()
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import pytest
|
|||||||
from aiohttp import WSMsgType, web
|
from aiohttp import WSMsgType, web
|
||||||
from aiohttp.test_utils import TestClient, TestServer
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
from webterm.config import App, Config
|
||||||
from textual_webterm.local_server import LocalServer
|
from webterm.local_server import LocalServer
|
||||||
from textual_webterm.types import RouteKey, SessionID
|
from webterm.types import RouteKey, SessionID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class TestConstants:
|
|||||||
|
|
||||||
def test_import(self):
|
def test_import(self):
|
||||||
"""Test module can be imported."""
|
"""Test module can be imported."""
|
||||||
from textual_webterm import constants
|
from webterm import constants
|
||||||
|
|
||||||
assert constants is not None
|
assert constants is not None
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ class TestConstants:
|
|||||||
"""Test DEBUG constant exists and respects env var."""
|
"""Test DEBUG constant exists and respects env var."""
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
from textual_webterm import constants
|
from webterm import constants
|
||||||
|
|
||||||
assert hasattr(constants, "DEBUG")
|
assert hasattr(constants, "DEBUG")
|
||||||
assert isinstance(constants.DEBUG, bool)
|
assert isinstance(constants.DEBUG, bool)
|
||||||
@@ -33,7 +33,7 @@ class TestExitPoller:
|
|||||||
|
|
||||||
def test_import(self):
|
def test_import(self):
|
||||||
"""Test module can be imported."""
|
"""Test module can be imported."""
|
||||||
from textual_webterm.exit_poller import ExitPoller
|
from webterm.exit_poller import ExitPoller
|
||||||
|
|
||||||
assert ExitPoller is not None
|
assert ExitPoller is not None
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ class TestExitPoller:
|
|||||||
"""ExitPoller should call force_exit after idle_wait seconds with no sessions."""
|
"""ExitPoller should call force_exit after idle_wait seconds with no sessions."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from textual_webterm import exit_poller
|
from webterm import exit_poller
|
||||||
from textual_webterm.exit_poller import ExitPoller
|
from webterm.exit_poller import ExitPoller
|
||||||
|
|
||||||
# Speed up the poll loop for the unit test.
|
# Speed up the poll loop for the unit test.
|
||||||
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
|
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm.poller import Poller, Write
|
from webterm.poller import Poller, Write
|
||||||
|
|
||||||
|
|
||||||
class TestWrite:
|
class TestWrite:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm.session import Session, SessionConnector
|
from webterm.session import Session, SessionConnector
|
||||||
from textual_webterm.types import RouteKey, SessionID
|
from webterm.types import RouteKey, SessionID
|
||||||
|
|
||||||
|
|
||||||
class TestSessionConnector:
|
class TestSessionConnector:
|
||||||
@@ -68,14 +68,14 @@ class TestIdentity:
|
|||||||
|
|
||||||
def test_generate_unique_ids(self) -> None:
|
def test_generate_unique_ids(self) -> None:
|
||||||
"""Test that generated IDs are unique."""
|
"""Test that generated IDs are unique."""
|
||||||
from textual_webterm.identity import generate
|
from webterm.identity import generate
|
||||||
|
|
||||||
ids = [generate() for _ in range(100)]
|
ids = [generate() for _ in range(100)]
|
||||||
assert len(set(ids)) == 100 # All unique
|
assert len(set(ids)) == 100 # All unique
|
||||||
|
|
||||||
def test_generate_id_format(self) -> None:
|
def test_generate_id_format(self) -> None:
|
||||||
"""Test that generated IDs have expected format."""
|
"""Test that generated IDs have expected format."""
|
||||||
from textual_webterm.identity import generate
|
from webterm.identity import generate
|
||||||
|
|
||||||
id_ = generate()
|
id_ = generate()
|
||||||
assert isinstance(id_, str)
|
assert isinstance(id_, str)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm.config import App
|
from webterm.config import App
|
||||||
from textual_webterm.session_manager import SessionManager
|
from webterm.session_manager import SessionManager
|
||||||
from textual_webterm.types import RouteKey, SessionID
|
from webterm.types import RouteKey, SessionID
|
||||||
|
|
||||||
|
|
||||||
class TestSessionManager:
|
class TestSessionManager:
|
||||||
@@ -173,7 +173,7 @@ class TestSessionManager:
|
|||||||
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
|
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
|
||||||
async def test_new_terminal_session(self, mock_poller, mock_path):
|
async def test_new_terminal_session(self, mock_poller, mock_path):
|
||||||
"""Test creating a new terminal session."""
|
"""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)
|
app = App(name="Terminal", slug="term", path="./", command="echo test", terminal=True)
|
||||||
manager = SessionManager(mock_poller, mock_path, [app])
|
manager = SessionManager(mock_poller, mock_path, [app])
|
||||||
@@ -190,25 +190,6 @@ class TestSessionManager:
|
|||||||
assert SessionID("test-session") in manager.sessions
|
assert SessionID("test-session") in manager.sessions
|
||||||
assert RouteKey("test-route") in manager.routes
|
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:
|
class TestSessionManagerRoutes:
|
||||||
"""Tests for SessionManager route handling."""
|
"""Tests for SessionManager route handling."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for slugify module."""
|
"""Tests for slugify module."""
|
||||||
|
|
||||||
from textual_webterm.slugify import slugify
|
from webterm.slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
class TestSlugify:
|
class TestSlugify:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm.svg_exporter import (
|
from webterm.svg_exporter import (
|
||||||
ANSI_COLORS,
|
ANSI_COLORS,
|
||||||
DEFAULT_BG,
|
DEFAULT_BG,
|
||||||
DEFAULT_FG,
|
DEFAULT_FG,
|
||||||
|
|||||||
@@ -21,19 +21,19 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
def test_import(self):
|
def test_import(self):
|
||||||
"""Test that module can be imported."""
|
"""Test that module can be imported."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
assert TerminalSession is not None
|
assert TerminalSession is not None
|
||||||
|
|
||||||
def test_replay_buffer_size(self):
|
def test_replay_buffer_size(self):
|
||||||
"""Test replay buffer size constant."""
|
"""Test replay buffer size constant."""
|
||||||
from textual_webterm.terminal_session import REPLAY_BUFFER_SIZE
|
from webterm.terminal_session import REPLAY_BUFFER_SIZE
|
||||||
|
|
||||||
assert REPLAY_BUFFER_SIZE == 256 * 1024 # 64KB
|
assert REPLAY_BUFFER_SIZE == 256 * 1024 # 64KB
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Test TerminalSession initialization."""
|
"""Test TerminalSession initialization."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -46,17 +46,26 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
def test_init_default_shell(self):
|
def test_init_default_shell(self):
|
||||||
"""Test that default shell is used when command is empty."""
|
"""Test that default shell is used when command is empty."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
|
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
|
||||||
session = TerminalSession(mock_poller, "test-session", "")
|
session = TerminalSession(mock_poller, "test-session", "")
|
||||||
assert session.command == "/bin/zsh"
|
assert session.command == "/bin/zsh"
|
||||||
|
|
||||||
|
def test_package_version_fallback(self):
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_replay_buffer_add(self):
|
async def test_replay_buffer_add(self):
|
||||||
"""Test adding data to replay buffer."""
|
"""Test adding data to replay buffer."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -68,7 +77,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_replay_buffer_multiple_adds(self):
|
async def test_replay_buffer_multiple_adds(self):
|
||||||
"""Test adding multiple chunks to replay buffer."""
|
"""Test adding multiple chunks to replay buffer."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -80,7 +89,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_replay_buffer_overflow(self):
|
async def test_replay_buffer_overflow(self):
|
||||||
"""Test that replay buffer trims old data when exceeding limit."""
|
"""Test that replay buffer trims old data when exceeding limit."""
|
||||||
from textual_webterm.terminal_session import (
|
from webterm.terminal_session import (
|
||||||
REPLAY_BUFFER_SIZE,
|
REPLAY_BUFFER_SIZE,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
)
|
)
|
||||||
@@ -99,7 +108,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_screen_state_updates_with_data(self):
|
async def test_screen_state_updates_with_data(self):
|
||||||
"""Test that pyte screen updates when data is received."""
|
"""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()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -114,7 +123,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_screen_handles_cursor_positioning(self):
|
async def test_screen_handles_cursor_positioning(self):
|
||||||
"""Test that pyte screen correctly handles cursor positioning (tmux-style)."""
|
"""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()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -133,7 +142,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_state_returns_dirty_flag(self):
|
async def test_get_screen_state_returns_dirty_flag(self):
|
||||||
"""Test that get_screen_state returns has_changes flag based on pyte dirty tracking."""
|
"""Test that get_screen_state returns has_changes flag based on pyte dirty tracking."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -158,7 +167,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
def test_update_connector(self):
|
def test_update_connector(self):
|
||||||
"""Test updating connector."""
|
"""Test updating connector."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -169,7 +178,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
def test_is_running_not_started(self):
|
def test_is_running_not_started(self):
|
||||||
"""Test is_running when session not started."""
|
"""Test is_running when session not started."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -179,7 +188,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_no_fd(self):
|
async def test_send_bytes_no_fd(self):
|
||||||
"""Test send_bytes returns False when no master_fd."""
|
"""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()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -190,7 +199,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_meta(self):
|
async def test_send_meta(self):
|
||||||
"""Test send_meta returns True."""
|
"""Test send_meta returns True."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -201,7 +210,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_no_pid(self):
|
async def test_close_no_pid(self):
|
||||||
"""Test close when no pid."""
|
"""Test close when no pid."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -212,7 +221,7 @@ class TestTerminalSession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wait_no_task(self):
|
async def test_wait_no_task(self):
|
||||||
"""Test wait when no task."""
|
"""Test wait when no task."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -222,7 +231,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
def test_repr(self):
|
def test_repr(self):
|
||||||
"""Test repr output."""
|
"""Test repr output."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
@@ -233,7 +242,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_uses_shlex_split_and_execvp_with_args(self):
|
async def test_open_uses_shlex_split_and_execvp_with_args(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
command = 'echo "hello world"'
|
command = 'echo "hello world"'
|
||||||
@@ -241,15 +250,15 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
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,
|
) as mock_fork,
|
||||||
patch("textual_webterm.terminal_session.version", return_value="0.0.0"),
|
patch("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.shlex.split", wraps=shlex.split) as mock_split,
|
||||||
patch(
|
patch(
|
||||||
"textual_webterm.terminal_session.os.execvp", side_effect=OSError()
|
"webterm.terminal_session.os.execvp", side_effect=OSError()
|
||||||
) as mock_execvp,
|
) as mock_execvp,
|
||||||
patch(
|
patch(
|
||||||
"textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
||||||
) as mock_exit,
|
) as mock_exit,
|
||||||
pytest.raises(SystemExit),
|
pytest.raises(SystemExit),
|
||||||
):
|
):
|
||||||
@@ -262,13 +271,13 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_parent_branch_sets_fd_and_pid(self):
|
async def test_open_parent_branch_sets_fd_and_pid(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
|
|
||||||
with (
|
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,
|
patch.object(session, "_set_terminal_size") as set_size,
|
||||||
):
|
):
|
||||||
await session.open(width=80, height=24)
|
await session.open(width=80, height=24)
|
||||||
@@ -279,16 +288,16 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_bad_command_exits(self):
|
async def test_open_bad_command_exits(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bad")
|
session = TerminalSession(poller, "sid", "bad")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
|
patch("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.shlex.split", side_effect=ValueError("bad")),
|
||||||
patch(
|
patch(
|
||||||
"textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
||||||
) as mock_exit,
|
) as mock_exit,
|
||||||
pytest.raises(SystemExit),
|
pytest.raises(SystemExit),
|
||||||
):
|
):
|
||||||
@@ -298,7 +307,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_lines_strips(self):
|
async def test_get_screen_lines_strips(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -319,7 +328,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_state_no_changes(self):
|
async def test_get_screen_state_no_changes(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -352,7 +361,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_state_clears_dirty(self):
|
async def test_get_screen_state_clears_dirty(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -389,7 +398,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_screen_has_changes_reads_dirty(self):
|
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()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -414,7 +423,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_handles_closed_fd(self):
|
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 = MagicMock()
|
||||||
poller.write = AsyncMock(side_effect=KeyError)
|
poller.write = AsyncMock(side_effect=KeyError)
|
||||||
@@ -426,7 +435,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_reads_from_poller_and_closes(self):
|
async def test_run_reads_from_poller_and_closes(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||||
await queue.put(b"hello")
|
await queue.put(b"hello")
|
||||||
@@ -444,7 +453,7 @@ class TestTerminalSession:
|
|||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._connector = connector
|
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()
|
await session.run()
|
||||||
|
|
||||||
connector.on_data.assert_awaited_once_with(b"hello")
|
connector.on_data.assert_awaited_once_with(b"hello")
|
||||||
@@ -454,7 +463,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_updates_connector_when_already_running(self):
|
async def test_start_updates_connector_when_already_running(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -472,7 +481,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_writes_via_poller(self):
|
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 = MagicMock()
|
||||||
poller.write = AsyncMock()
|
poller.write = AsyncMock()
|
||||||
@@ -485,15 +494,15 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_set_terminal_size_oserror_closes_fd_and_clears_master_fd(self):
|
async def test_open_set_terminal_size_oserror_closes_fd_and_clears_master_fd(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
|
|
||||||
with (
|
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.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),
|
pytest.raises(OSError),
|
||||||
):
|
):
|
||||||
await session.open(width=80, height=24)
|
await session.open(width=80, height=24)
|
||||||
@@ -503,7 +512,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_terminal_size_uses_executor(self):
|
async def test_set_terminal_size_uses_executor(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
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)
|
run_in_executor.assert_awaited_once_with(None, session._set_terminal_size, 80, 24)
|
||||||
|
|
||||||
def test__set_terminal_size_calls_ioctl(self):
|
def test__set_terminal_size_calls_ioctl(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
session.master_fd = 10
|
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)
|
session._set_terminal_size(80, 24)
|
||||||
|
|
||||||
assert mock_ioctl.called
|
assert mock_ioctl.called
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_creates_task_when_not_running(self):
|
async def test_start_creates_task_when_not_running(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -547,7 +556,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_without_connector_still_closes(self):
|
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()
|
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||||
await queue.put(b"hello")
|
await queue.put(b"hello")
|
||||||
@@ -561,7 +570,7 @@ class TestTerminalSession:
|
|||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._connector = None
|
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()
|
await session.run()
|
||||||
|
|
||||||
poller.remove_file.assert_called_once_with(10)
|
poller.remove_file.assert_called_once_with(10)
|
||||||
@@ -569,7 +578,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_oserror_still_closes(self):
|
async def test_run_oserror_still_closes(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
queue = MagicMock()
|
queue = MagicMock()
|
||||||
queue.get = AsyncMock(side_effect=OSError("boom"))
|
queue.get = AsyncMock(side_effect=OSError("boom"))
|
||||||
@@ -582,7 +591,7 @@ class TestTerminalSession:
|
|||||||
session.master_fd = 10
|
session.master_fd = 10
|
||||||
session._connector = None
|
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()
|
await session.run()
|
||||||
|
|
||||||
poller.remove_file.assert_called_once_with(10)
|
poller.remove_file.assert_called_once_with(10)
|
||||||
@@ -590,26 +599,26 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_process_lookup_error_is_ignored(self):
|
async def test_close_process_lookup_error_is_ignored(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
session.pid = 123
|
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()
|
await session.close()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_logs_warning_on_unexpected_exception(self):
|
async def test_close_logs_warning_on_unexpected_exception(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
session.pid = 123
|
session.pid = 123
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("textual_webterm.terminal_session.os.kill", side_effect=RuntimeError("x")),
|
patch("webterm.terminal_session.os.kill", side_effect=RuntimeError("x")),
|
||||||
patch("textual_webterm.terminal_session.log.warning") as warn,
|
patch("webterm.terminal_session.log.warning") as warn,
|
||||||
):
|
):
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
@@ -617,7 +626,7 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wait_suppresses_cancelled_error(self):
|
async def test_wait_suppresses_cancelled_error(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -629,7 +638,7 @@ class TestTerminalSession:
|
|||||||
await session.wait()
|
await session.wait()
|
||||||
|
|
||||||
def test_is_running_false_when_kill_fails(self):
|
def test_is_running_false_when_kill_fails(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
poller = MagicMock()
|
poller = MagicMock()
|
||||||
session = TerminalSession(poller, "sid", "bash")
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
@@ -637,5 +646,5 @@ class TestTerminalSession:
|
|||||||
session._task = MagicMock()
|
session._task = MagicMock()
|
||||||
session.pid = 123
|
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
|
assert session.is_running() is False
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from textual_webterm._two_way_dict import TwoWayDict
|
from webterm._two_way_dict import TwoWayDict
|
||||||
|
|
||||||
|
|
||||||
class TestTwoWayDict:
|
class TestTwoWayDict:
|
||||||
|
|||||||
+1
-1
@@ -12,6 +12,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
},
|
},
|
||||||
"include": ["src/textual_webterm/static/js/**/*.ts"],
|
"include": ["src/webterm/static/js/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user