Finalize Go-only migration, runtime hardening, and CI/container optimization

This commit consolidates the full repository transition to a Go-first codebase and captures the follow-up performance/reliability work completed in the same stream.

Highlights:
- Remove Python implementation and test suites (, , , ) and retire Python-specific docs/instructions.
- Move and standardize static web assets under , updating Bun/TypeScript build paths and server static resolution logic.
- Rewrite developer workflow to Makefile-first Go targets (vet/test/race/coverage/fuzz/build) and align repository guidance/docs accordingly.
- Update Docker and CI/CD for leaner artifacts:
  - switch to Alpine-based multi-stage build with stripped Go binary
  - install only minimal runtime deps (, )
  - tighten Docker build context via
  - ensure workflows build/publish the  target.
- Improve runtime correctness/latency and reduce duplication:
  - explicit WebSocket outbound frame typing (text vs binary) instead of payload-byte heuristics
  - SSE activity fan-out outside global lock and safer subscriber lifecycle
  - shared session output/snapshot helpers to reduce duplicated logic
  - restart-safe channel lifecycle for Docker watcher/stats start-stop-start flows
  - faster screenshot cold-start path (poll-until-ready within timeout vs fixed sleep).
- Add/expand regression coverage for the above lifecycle and helper paths.

Validation run:
- bun run build
Bundled 3 modules in 10ms

  terminal.js  0.68 MB  (entry point) (Bun typecheck + bundle)
- cd go && go vet ./...
cd go && go test ./...
ok  	github.com/rcarmo/webterm-go-port/cmd/webterm	(cached)
ok  	github.com/rcarmo/webterm-go-port/internal/terminalstate	(cached)
ok  	github.com/rcarmo/webterm-go-port/webterm	(cached)
cd go && go test ./webterm -coverprofile=coverage.out && go tool cover -func=coverage.out
ok  	github.com/rcarmo/webterm-go-port/webterm	(cached)	coverage: 81.0% of statements
github.com/rcarmo/webterm-go-port/webterm/cli.go:14:			RunCLI				51.6%
github.com/rcarmo/webterm-go-port/webterm/config.go:25:			DefaultConfig			100.0%
github.com/rcarmo/webterm-go-port/webterm/config.go:29:			LoadLandingYAML			82.6%
github.com/rcarmo/webterm-go-port/webterm/config.go:70:			LoadComposeManifest		76.9%
github.com/rcarmo/webterm-go-port/webterm/config.go:114:		extractLabel			92.3%
github.com/rcarmo/webterm-go-port/webterm/config.go:138:		asString			80.0%
github.com/rcarmo/webterm-go-port/webterm/constants.go:27:		EnvBool				50.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:30:	Read				100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:56:	NewDockerExecSession		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:72:	Open				90.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:99:	Start				85.7%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:119:	readLoop			83.3%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:143:	handleOutput			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:153:	createExec			75.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:183:	startExecSocket			60.7%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:219:	resizeExec			83.3%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:237:	Close				90.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:250:	Wait				100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:257:	SetTerminalSize			81.8%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:274:	ForceRedraw			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:281:	SendBytes			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:294:	SendMeta			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:298:	IsRunning			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:304:	GetReplayBuffer			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:308:	GetScreenSnapshot		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:316:	UpdateConnector			80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:23:		DockerSocketPath		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:37:		newUnixHTTPClient		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:47:		sharedUnixClient		91.7%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:64:		unixJSONRequest			84.2%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:33:		NewDockerStatsCollector		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:47:		Available			72.7%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:64:		Start				80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:78:		Stop				75.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:90:		AddService			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:101:		RemoveService			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:115:		GetCPUHistory			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:124:		pollLoop			95.2%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:160:		discoverContainers		65.4%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:198:		pollContainer			77.8%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:223:		calculateCPUPercent		69.2%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:260:		RenderSparklineSVG		89.3%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:299:		max				100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:306:		toAnyMap			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:323:		toStringMap			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:331:		toAnySlice			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:340:		toStringSlice			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:351:		toUint				91.7%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:376:		toInt				85.7%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:34:		NewDockerWatcher		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:54:		hasWebtermLabel			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:60:		isAutoLabel			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:67:		getContainerCommand		80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:76:		getContainerTheme		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:81:		getContainerName		42.9%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:93:		containerToSlug			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:98:		addContainer			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:119:	removeContainer			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:143:	listLabeledContainers		94.1%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:168:	handleEvent			80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:202:	watchEvents			85.7%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:239:	ScanExisting			60.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:249:	Start				83.3%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:265:	Stop				81.8%
github.com/rcarmo/webterm-go-port/webterm/identity.go:12:		GenerateID			94.1%
github.com/rcarmo/webterm-go-port/webterm/normalize.go:13:		FilterDASequences		83.3%
github.com/rcarmo/webterm-go-port/webterm/replay.go:14:			NewReplayBuffer			66.7%
github.com/rcarmo/webterm-go-port/webterm/replay.go:21:			Add				100.0%
github.com/rcarmo/webterm-go-port/webterm/replay.go:43:			Bytes				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:95:			OnData				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go💯		OnBinary			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:105:		OnMeta				0.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:107:		OnClose				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:112:		NewLocalServer			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:163:		findStaticPath			62.5%
github.com/rcarmo/webterm-go-port/webterm/server.go:184:		markRouteActivity		100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:207:		enqueueWSFrame			77.8%
github.com/rcarmo/webterm-go-port/webterm/server.go:233:		stopWSClient			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:246:		wsSender			80.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:256:		createTerminalSession		66.7%
github.com/rcarmo/webterm-go-port/webterm/server.go:278:		clampInt			60.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:288:		parseResizePayload		88.9%
github.com/rcarmo/webterm-go-port/webterm/server.go:303:		handleWebSocket			81.1%
github.com/rcarmo/webterm-go-port/webterm/server.go:425:		chooseRouteForScreenshot	50.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:440:		screenshotTTL			66.7%
github.com/rcarmo/webterm-go-port/webterm/server.go:457:		handleScreenshot		55.7%
github.com/rcarmo/webterm-go-port/webterm/server.go:541:		handleCPUSparkline		94.4%
github.com/rcarmo/webterm-go-port/webterm/server.go:566:		handleEvents			76.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:602:		toIntFromQuery			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:609:		dashboardTiles			81.8%
github.com/rcarmo/webterm-go-port/webterm/server.go:631:		handleTiles			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:636:		getWSURL			65.2%
github.com/rcarmo/webterm-go-port/webterm/server.go:671:		handleRoot			56.8%
github.com/rcarmo/webterm-go-port/webterm/server.go:732:		htmlEscape			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:736:		htmlAttrEscape			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:740:		handleHealth			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:744:		setupDockerFeatures		40.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:791:		shutdown			62.5%
github.com/rcarmo/webterm-go-port/webterm/server.go:814:		Handler				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:829:		Run				77.8%
github.com/rcarmo/webterm-go-port/webterm/session.go:31:		OnData				0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:32:		OnBinary			0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:33:		OnMeta				0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:34:		OnClose				0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:36:		dispatchSessionOutput		100.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:47:		snapshotFromTracker		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:20:	NewSessionManager		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:34:	SetSessionFactory		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:40:	defaultSessionFactory		87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:57:	splitCommand			75.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:65:	shlexSplit			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:69:	AddApp				87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:88:	RemoveApp			87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:101:	Apps				100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:107:	AppBySlug			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:114:	GetDefaultApp			80.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:123:	NewSession			50.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:167:	OnSessionEnd			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:176:	CloseAll			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:189:	CloseSession			87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:201:	GetSession			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:207:	GetSessionByRouteKey		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:217:	GetSessionIDByRouteKey		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:223:	GetFirstRunningSession		85.7%
github.com/rcarmo/webterm-go-port/webterm/shellsplit.go:5:		shlexSplitImpl			100.0%
github.com/rcarmo/webterm-go-port/webterm/slugify.go:13:		Slugify				100.0%
github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:35:		RenderTerminalSVG		92.6%
github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:113:		colorToHex			87.5%
github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:139:		isHex				100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:37:	NewTerminalSession		100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:49:	Open				86.7%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:90:	Start				85.7%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:110:	readLoop			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:132:	handleOutput			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:142:	Close				100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:160:	Wait				100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:167:	SetTerminalSize			80.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:190:	ForceRedraw			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:198:	SendBytes			88.9%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:211:	SendMeta			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:215:	IsRunning			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:221:	GetReplayBuffer			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:225:	GetScreenSnapshot		100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:233:	UpdateConnector			80.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:14:			NewTwoWayMap			100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:21:			Set				88.9%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:36:			DeleteKey			100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:45:			Get				100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:52:			GetKey				100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:59:			Keys				100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:70:			UnsafeForward			100.0%
total:									(statements)			81.0%
- cd go && go test -race ./...
ok  	github.com/rcarmo/webterm-go-port/cmd/webterm	(cached)
ok  	github.com/rcarmo/webterm-go-port/internal/terminalstate	(cached)
ok  	github.com/rcarmo/webterm-go-port/webterm	(cached)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 18:03:28 +00:00
parent 065de286fb
commit 3d4dab2359
81 changed files with 447 additions and 13326 deletions
+5 -29
View File
@@ -1,29 +1,5 @@
# 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
LICENSE
*
!go/
!go/**
!Dockerfile
!.dockerignore
+13 -36
View File
@@ -2,50 +2,27 @@
## Makefile Usage (MANDATORY)
Always use the Makefile for development tasks. Never run raw `pytest`, `ruff`, or `bun` commands directly.
Always use the Makefile for development tasks. Never run raw `go test`, `go vet`, or `bun` commands directly.
### Quick Reference
| Task | Command | Description |
|------|---------|-------------|
| Run tests | `make test` | Run pytest |
| Run linter | `make lint` | Run ruff linter |
| Format code | `make format` | Auto-format with ruff |
| Run tests | `make test` | Run Go tests |
| Run linter | `make lint` | Run `go vet` |
| Format code | `make format` | Run `gofmt` |
| Coverage report | `make coverage` | Run Go coverage with `-coverpkg` |
| Full check | `make check` | Run lint + coverage |
| Coverage report | `make coverage` | Run pytest with coverage |
| Race tests | `make race` | Run `go test -race` |
| Fuzz smoke run | `make fuzz` | Run all fuzz targets briefly |
| Build frontend | `make build` | TypeScript typecheck + bundle |
| Quick frontend build | `make build-fast` | Bundle without typecheck |
| Watch mode | `make bundle-watch` | Frontend dev with auto-rebuild |
| Install dev deps | `make install-dev` | Install package + dev dependencies |
| Clean build | `make build-all` | Full reproducible build from scratch |
| Build Go binary | `make build-go` | Build CLI binary |
| Full rebuild | `make build-all` | Clean + deps + build + checks |
| See all targets | `make help` | Show all available commands |
### Development Workflow
1. **Before making changes**: Run `make check` to establish baseline
2. **After making changes**: Run `make check` to verify no regressions
3. **Frontend changes**: Use `make build` or `make build-fast`
4. **Full rebuild**: Use `make build-all` (cleans everything first)
### Clean Targets
- `make clean` - Remove Python cache files only
- `make bundle-clean` - Remove frontend build artifacts (node_modules, etc.)
- `make clean-all` - Remove everything
### Version Management
- `make bump-patch` - Bump patch version and create git tag
## Testing Guidelines
- Aim for good test coverage
- Review tests periodically to consolidate/parameterize and remove redundancy
- Use fixtures from `tests/conftest.py` instead of duplicating setup code
- Prefer parameterized tests for similar test cases
## Code Style
- Do not use heredocs or random shell commands
- Prefer `make` and ecosystem tools (pip, bun) over manual operations
- Debug issues systematically - search for and review documentation as needed
1. **Before changes**: Run `make check` to establish baseline.
2. **After changes**: Run `make check` and `make race`.
3. **Frontend edits**: Run `make build` after changing `go/webterm/static/js/terminal.ts`.
4. **Major validation**: Run `make build-all` for a reproducible full run.
@@ -2,7 +2,6 @@
Use these heuristics to decide which other instruction files apply:
- If `pyproject.toml` or `requirements*.txt` exists -> apply `python.instructions.md`.
- If `go.mod` exists -> apply `go.instructions.md`.
- If `Dockerfile` exists and CI is publishing images -> apply `docker-image.instructions.md`.
- If `package.json` exists (or Bun is referenced) -> apply `frontend-bun.instructions.md`.
+2 -2
View File
@@ -3,9 +3,9 @@
Applies when: this repo has `go.mod`.
## Makefile-first workflow
- CI should run `make check` and Go tests (`cd go && go test ./...` in this repository).
- CI should run `make check`, and `make race` for concurrency-sensitive paths.
- Put `golangci-lint` and `gosec` wiring behind Make targets when introduced.
## Conventions to implement
- `make test` should run `go test ./...` (prefer `-race` and coverage in CI).
- `make test` should run `cd go && go test ./...`.
- Avoid bespoke CI steps when a Make target can encode the same behavior.
@@ -1,12 +0,0 @@
# Python project instructions
Applies when: this repo has `pyproject.toml` or `requirements*.txt`.
## Makefile-first workflow
- Prefer `make check` as the default validation gate.
- If you add/change tooling, wire it through Make targets (don't ask users to run raw `pytest`/`ruff`).
## Conventions to implement
- Prefer `ruff` for lint+format.
- Prefer `python -m pytest` (and `--cov` for coverage) via Make targets.
- Keep CI calling Make targets, not raw commands.
+17 -54
View File
@@ -1,65 +1,28 @@
# Screenshot Debugging Skill
## Purpose
Diagnose terminal screenshot corruption caused by incomplete escape-sequence handling.
Diagnose terminal screenshot corruption caused by incomplete terminal-state updates.
## When to Use
- SVG screenshot shows stale or overlaid content after clear/redraw.
- Behavior differs between live terminal output and screenshot snapshots.
- Issues appear inside tmux/vim/less or other full-screen TUIs.
- SVG screenshots show stale or overlaid text.
- Live WebSocket output looks correct but `/screenshot.svg` does not.
- Issues appear with full-screen TUIs (tmux/vim/less).
## Procedure
1. **Reproduce and capture raw output**
- Capture PTY output around the failing action (e.g., `clear` inside tmux).
- Ensure capture includes the full sequence before and after the command.
2. **Replay into the emulator**
- Feed captured bytes into the same emulator used for screenshots (pyte + AltScreen).
- Inspect the rendered buffer for stale cells or overlay.
3. **Scan for unhandled escape modes**
- Look for private modes: `?47`, `?1047`, `?1048`, `?1049`.
- Check erase semantics: `ED` (`J`), `EL` (`K`), `ECH` (`X`).
- Verify C1 controls are normalized to 7-bit ESC equivalents.
4. **Fix emulator handling**
- Update AltScreen to recognize any missing alternate buffer modes (e.g., `?47`).
- Ensure mode toggles save/restore the main buffer and mark dirty lines.
1. **Capture terminal bytes**
- Capture PTY output around the failing action.
2. **Replay into the Go tracker**
- Feed bytes through `internal/terminalstate` (go-te based).
- Confirm whether buffer state diverges from expected terminal behavior.
3. **Check preprocessing/filtering**
- Validate DA filtering and any partial-sequence buffering logic.
4. **Check dirty/refresh logic**
- Verify tracker updates happen before screenshot cache decisions.
5. **Add regression coverage**
- Add a focused test that replays the sequence and asserts the buffer is cleared.
- Include any new mode variants in existing parameterized tests.
- Add a focused Go test and (when useful) fuzz corpus seed.
6. **Verify**
- Run `make check`.
- Re-test the real scenario and confirm screenshots match the live terminal.
## Minimal Capture Snippet (PTY -> pyte)
```python
import os, pty, select, time, pyte
from webterm.alt_screen import AltScreen
def read_all(fd, timeout=0.5):
out = b""
end = time.time() + timeout
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.05)
if not r:
continue
try:
data = os.read(fd, 4096)
except OSError:
break
if not data:
break
out += data
return out
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
stream.feed(raw_bytes)
```
- Run `make check` and re-test the real scenario.
## Notes
- tmux often uses `DECSET ?47` (legacy alt buffer) instead of `?1049`.
- Always validate with real output captures, not just synthetic sequences.
- Prefer reproductions from real PTY captures over synthetic minimal sequences.
- If rendering differs only in dashboard thumbnails, inspect SSE activity gating and screenshot cache TTL/invalidations.
+9
View File
@@ -35,3 +35,12 @@ jobs:
else
echo "No Go module found; skipping."
fi
- name: Build runtime image (if Dockerfile present)
run: |
set -e
if [ -f Dockerfile ]; then
docker build --target runtime --tag webterm:ci .
else
echo "No Dockerfile found; skipping."
fi
+2 -1
View File
@@ -51,6 +51,8 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
target: runtime
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
@@ -345,4 +347,3 @@ jobs:
core.setFailed(`Error managing package versions: ${error.message}`);
}
}
+14 -132
View File
@@ -1,142 +1,24 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.pyi
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Ruff
.ruff_cache/
# Translations
*.mo
*.pot
# Logs
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# Poetry
# poetry.lock is committed for applications, but can be gitignored for libraries
# Uncomment if this is a library:
# poetry.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Environments
.env
.env.*
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDEs and editors
.vscode/
.idea/
.vscode/
*.swp
*.swo
*~
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pyright
.pyright/
# cache / build artifacts
.cache/
.coverage
htmlcov/
# pdm
.pdm.toml
.pdm-python
.pdm-build/
# webterm specific
# Runtime artifacts
*.log
webterm.log
# Node.js / Bun (for development only)
# Coverage/build artifacts
go/coverage.out
coverage.out
dist/
build/
target/
# Go
go/bin/
# Frontend
node_modules/
bun.lockb
package-lock.json
+13 -27
View File
@@ -1,38 +1,24 @@
# Minimal image for serving a web terminal with Docker watch mode
#
# Build: docker build -t webterm .
# Run: docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 webterm --docker-watch
#
FROM python:3.12-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
make \
&& rm -rf /var/lib/apt/lists/*
FROM golang:1.26-alpine AS builder
# Copy only what's needed for installation
WORKDIR /build
COPY pyproject.toml poetry.lock* ./
COPY README.md ./
COPY src/ ./src/
# Install the package
RUN pip install --no-cache-dir .
WORKDIR /src
COPY go/go.mod go/go.sum ./go/
RUN cd go && go mod download
COPY go ./go
RUN cd go && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/webterm ./cmd/webterm
# Final minimal image
FROM python:3.12-slim
FROM alpine:3.21 AS runtime
# Install only runtime dependencies (docker CLI for exec commands)
RUN apt-get update && apt-get install -y --no-install-recommends \
docker.io \
&& rm -rf /var/lib/apt/lists/*
# Keep docker-cli for user-provided webterm-command values like `docker logs` / `docker exec`.
RUN apk add --no-cache ca-certificates docker-cli
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin/webterm /usr/local/bin/webterm
WORKDIR /app
COPY --from=builder /out/webterm /usr/local/bin/webterm
COPY go/webterm/static /app/static
# Create non-root user (optional, but may need root for Docker socket access)
# RUN useradd -m webterm
# USER webterm
ENV WEBTERM_STATIC_PATH=/app/static
EXPOSE 8080
+29 -68
View File
@@ -1,53 +1,38 @@
.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all build-fast bundle bundle-watch bundle-clean typecheck bump-patch push
.PHONY: help install install-dev lint format test race coverage check fuzz build-go build build-fast bundle bundle-watch bundle-clean clean clean-all build-all typecheck
PYTHON ?= python3
PIP ?= $(PYTHON) -m pip
# Static assets
STATIC_JS_DIR = src/webterm/static/js
GO_DIR = go
STATIC_JS_DIR = go/webterm/static/js
TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts
TERMINAL_JS = $(STATIC_JS_DIR)/terminal.js
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
# =============================================================================
# Full reproducible build
# =============================================================================
install: ## Download Go dependencies
cd $(GO_DIR) && go mod download
build-all: clean-all node_modules build install-dev check ## Full reproducible build (clean + deps + bundle + install)
@echo "Build complete!"
install-dev: install node_modules ## Install Go and frontend dependencies
# =============================================================================
# Python targets
# =============================================================================
# NOTE: Install dev tools (ruff/pytest/etc.) using `pip install --user --break-system-packages`
# from the dev dependency list (pyproject.toml or requirements-dev.txt).
lint: ## Run Go vet
cd $(GO_DIR) && go vet ./...
install: ## Install package in editable mode
$(PIP) install -e .
format: ## Format Go code
cd $(GO_DIR) && gofmt -w ./cmd ./internal ./webterm
install-dev: install ## Install with dev dependencies
$(PIP) install aiohttp uvloop click pydantic importlib-metadata tomli pyyaml pyte
$(PIP) install pytest pytest-asyncio pytest-cov pytest-timeout ruff
test: ## Run Go tests
cd $(GO_DIR) && go test ./...
lint: ## Run ruff linter
ruff check src tests
race: ## Run Go race tests
cd $(GO_DIR) && go test -race ./...
format: ## Format code with ruff
ruff format src tests
coverage: ## Run Go coverage for runtime package
cd $(GO_DIR) && go test ./webterm -coverprofile=coverage.out && go tool cover -func=coverage.out
test: ## Run pytest
pytest
check: lint test coverage ## Run lint + tests + coverage
coverage: ## Run pytest with coverage
pytest --cov=src/webterm --cov-report=term-missing
check: lint coverage ## Run lint + coverage
# =============================================================================
# Frontend build targets (requires Bun: https://bun.sh)
# =============================================================================
fuzz: ## Run all fuzz tests briefly
cd $(GO_DIR) && go test ./... -run=^$$ -fuzz=Fuzz -fuzztime=1s
node_modules: package.json
bun install
@@ -69,40 +54,16 @@ bundle-watch: node_modules ## Watch mode for frontend development
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
bun run watch
# =============================================================================
# Clean targets
# =============================================================================
build-go: ## Build Go CLI binary
cd $(GO_DIR) && mkdir -p bin && go build -o ./bin/webterm ./cmd/webterm
clean: ## Remove Python cache files
rm -rf .pytest_cache .coverage htmlcov .ruff_cache __pycache__ src/**/__pycache__
clean: ## Remove coverage artifacts
rm -f $(GO_DIR)/coverage.out
bundle-clean: ## Remove frontend build artifacts
rm -rf node_modules bun.lock $(TERMINAL_JS) $(GHOSTTY_WASM)
bundle-clean: ## Remove frontend dependencies
rm -rf node_modules bun.lock
clean-all: clean bundle-clean ## Remove everything (clean + bundle-clean)
clean-all: clean bundle-clean ## Remove all generated artifacts
# =============================================================================
# Version management
# =============================================================================
bump-patch: ## Bump patch version and create git tag
@OLD=$$(grep -Po '(?<=^version = ")[^"]+' pyproject.toml); \
MAJOR=$$(echo $$OLD | cut -d. -f1); \
MINOR=$$(echo $$OLD | cut -d. -f2); \
PATCH=$$(echo $$OLD | cut -d. -f3); \
NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
sed -i "s/^version = \"$$OLD\"/version = \"$$NEW\"/" pyproject.toml; \
git add pyproject.toml; \
git commit -m "Bump version to $$NEW"; \
git tag "v$$NEW"; \
echo "Bumped version: $$OLD -> $$NEW (tagged v$$NEW)"
push: ## Push commits and current tag to origin
@TAG=$$(git describe --tags --exact-match 2>/dev/null); \
git push origin main; \
if [ -n "$$TAG" ]; then \
echo "Pushing tag $$TAG..."; \
git push origin "$$TAG"; \
else \
echo "No tag on current commit"; \
fi
build-all: clean-all install-dev build check build-go ## Full reproducible build from scratch
@echo "Build complete!"
+94 -265
View File
@@ -1,289 +1,118 @@
# webterm
# webterm (Go)
![Icon](docs/icon-256.png)
Serve terminal sessions over the web with a simple CLI command. [Blog post](https://taoofmac.com/space/notes/2026/01/25/2030#seizing-the-means-of-production)
> **Credit and Inspiration:** This project was originally based on the genius [textual-web](https://github.com/Textualize/textual-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.
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.
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:
`webterm` serves terminal sessions over HTTP/WebSocket, with a dashboard mode for multiple sessions and Docker-aware tiles.
![Screenshot](docs/screenshot.png)
## Features
- **Web-based terminal** - Access your terminal from any browser
- **Mobile support** - Works on iOS Safari and Android with on-screen keyboard modifier (experimental) and touch selection
- **Session reconnection** - Refresh the page and reconnect to the same session
- **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly
- **Customizable themes** - 9 built-in themes (monokai, dracula, nord, etc.)
- **Custom fonts** - Configure terminal font family and size
- **Scrollback history** - Scroll back through terminal output (configurable)
- **Auto-sizing** - Terminal automatically resizes to fit the browser window
- **Live screenshots** - Dashboard shows real-time SVG screenshots of terminals
- **CPU sparklines** - Dashboard displays 30-minute CPU history for Docker containers
- **SSE updates** - Real-time screenshot updates via Server-Sent Events
- **Simple CLI** - One command to start serving
- Web terminal with reconnect support
- Session dashboard with live SVG screenshots
- Docker watch mode (`webterm-command` / `webterm-theme` labels)
- Docker compose manifest ingestion
- CPU sparkline tiles for compose services
- SSE activity updates for fast dashboard refresh
- Theme/font controls for terminal rendering
## Non-Features
## Install
- **No Authentication** - this is meant to be used inside a dedicated container, and you should set up an authenticating reverse proxy like `authelia`
- **No Encryption (TLS/HTTPS)** - again, this is meant to be fronted by something like `traefik` or `caddy`
## Known Issues
- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details.
## Installation
Install directly from GitHub:
```bash
pip install git+https://github.com/rcarmo/webterm.git
```
## Quick Start
### Serve a Terminal
Serve your default shell:
```bash
webterm
```
Serve a specific command:
```bash
webterm htop
```
### Options
Specify host and port:
```bash
webterm --host 0.0.0.0 --port 8080 bash
```
Customize theme and font:
```bash
webterm --theme dracula --font-size 18
webterm --theme nord --font-family "JetBrains Mono, monospace"
```
Available themes: `xterm` (default), `monokai`, `dark`, `light`, `dracula`, `catppuccin`, `nord`, `gruvbox`, `solarized`, `tokyo`.
Then open http://localhost:8080 in your browser.
## Session Dashboard
You can serve a dashboard with multiple terminal tiles driven by a YAML manifest:
```yaml
- name: My Service
slug: my-service
command: docker logs -f my-service
```
Run with:
```bash
webterm --landing-manifest landing.yaml
```
### Docker Watch Mode
Watch for Docker containers with `webterm-command` **or** `webterm-theme` labels and dynamically add/remove terminal sessions:
```bash
webterm --docker-watch
```
When a container starts with either label, it automatically appears in the dashboard. When it stops, it's removed. Label values:
- `webterm-command: auto` (or empty) - Opens a PTY via Docker exec API (override with `WEBTERM_DOCKER_AUTO_COMMAND`)
- `webterm-command: <command>` - Runs the specified command
- `webterm-theme: <theme>` - Sets the terminal theme for that container (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo). Invalid themes fall back to `tango` and the page background defaults to black.
Containers that only specify `webterm-theme` are still included and use the default auto command.
**Environment Variables:**
- `WEBTERM_DOCKER_USERNAME` - Set to run Docker exec sessions as a specific user (default: root)
- `WEBTERM_DOCKER_AUTO_COMMAND` - Override the default `auto` command (default: `/bin/bash`). Supports `{container}` placeholder for the container name.
- `WEBTERM_SCREENSHOT_FORCE_REDRAW` - When set to `true`, send a SIGWINCH-style redraw before generating screenshots (default: false).
Example: Start containers and exec into them as `developer` user:
```bash
WEBTERM_DOCKER_USERNAME=developer webterm --docker-watch
```
Example: Use tmux with per-container session names:
```bash
WEBTERM_DOCKER_AUTO_COMMAND="tmux new-session -ADs {container}" webterm --docker-watch
```
This creates a tmux session named after each container (e.g., `my-webapp`, `redis`, etc.) instead of a shared session name.
Example docker-compose.yaml:
```yaml
services:
myapp:
image: myapp:latest
labels:
webterm-command: auto # Opens bash in container
webterm-theme: monokai
logs:
image: myapp:latest
labels:
webterm-command: docker logs -f myapp # Shows logs
webterm-theme: nord
```
**Requires**: Docker socket access (`-v /var/run/docker.sock:/var/run/docker.sock`)
### Docker Compose Integration
Point to a docker-compose file; services with the label `webterm-command` become tiles (and `webterm-theme` applies there too):
```yaml
services:
db:
image: postgres
labels:
webterm-command: docker exec -it db psql
webterm-theme: gruvbox
```
Start with:
```bash
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`).
### Dashboard Features
- **Live screenshots** - Terminal thumbnails update in real-time via SSE when activity occurs
- **Dynamic updates** - In docker-watch mode, tiles appear/disappear as containers start/stop
- **CPU sparklines** - Mini charts showing container CPU usage (compose mode only)
- **Tab reuse** - Clicking the same tile reopens the existing browser tab
- **Auto-focus** - Terminals automatically receive keyboard focus on load
## CLI Reference
```
Usage: webterm [OPTIONS] [COMMAND]
Serve a terminal over HTTP/WebSocket.
COMMAND: Shell command to run in terminal (default: $SHELL)
Options:
-H, --host TEXT Host to bind to [default: 0.0.0.0]
-p, --port INTEGER Port to bind to [default: 8080]
-L, --landing-manifest PATH YAML manifest describing landing page tiles
(slug/name/command).
-C, --compose-manifest PATH Docker compose YAML; services with label
"webterm-command" become landing tiles.
-D, --docker-watch Watch Docker for containers with
"webterm-command" label (dynamic mode).
-t, --theme TEXT Terminal color theme [default: xterm]
Options: xterm, monokai, dark, light, dracula,
catppuccin, nord, gruvbox, solarized, tokyo
-f, --font-family TEXT Terminal font family (CSS font stack)
-s, --font-size INTEGER Terminal font size in pixels [default: 16]
--version Show the version and exit.
--help Show this message and exit.
```
## API Endpoints
| Endpoint | Description |
|----------|-------------|
| `/` | Dashboard (with manifest/docker-watch) or terminal view |
| `/ws/{route_key}` | WebSocket for terminal I/O |
| `/screenshot.svg?route_key=...` | SVG screenshot of terminal |
| `/cpu-sparkline.svg?container=...` | CPU sparkline SVG (compose mode) |
| `/tiles` | JSON list of current tiles (for dynamic dashboards) |
| `/events` | SSE stream for activity notifications |
| `/health` | Health check endpoint |
## Development
### Setup (Makefile-first)
### Build from source
```bash
git clone https://github.com/rcarmo/webterm.git
cd webterm
cd webterm/go
mkdir -p bin
go build -o ./bin/webterm ./cmd/webterm
```
# Install with dev dependencies via Makefile
The command above produces `go/bin/webterm`; you can also build it from repo root with `make build-go`.
## Quick start
Run a default shell session:
```bash
cd go
go run ./cmd/webterm
```
Run a specific command:
```bash
cd go
go run ./cmd/webterm -- htop
```
Then open <http://localhost:8080>.
## Dashboard modes
### Landing manifest
```yaml
- name: Logs
slug: logs
command: docker logs -f my-service
theme: nord
```
```bash
cd go
go run ./cmd/webterm -- --landing-manifest ../landing.yaml
```
### Docker watch
```bash
cd go
go run ./cmd/webterm -- --docker-watch
```
Containers with these labels become tiles:
- `webterm-command`: command string, or `auto` for Docker exec
- `webterm-theme`: theme name (fallback is `xterm` palette)
### Compose manifest
```bash
cd go
go run ./cmd/webterm -- --compose-manifest ../docker-compose.yaml
```
## Environment variables
- `WEBTERM_STATIC_PATH`: override static asset directory
- `WEBTERM_DOCKER_USERNAME`: user for Docker exec sessions
- `WEBTERM_DOCKER_AUTO_COMMAND`: override auto command (`/bin/bash` default)
- `WEBTERM_SCREENSHOT_FORCE_REDRAW`: force redraw before screenshots (`true/1/yes/on`)
- `DOCKER_HOST`: Docker daemon endpoint override
## Development (Makefile-first)
```bash
make install-dev
make check
make race
make test
```
### Common tasks (use Makefile)
- Lint: `make lint`
- Format: `make format`
- Tests: `make test`
- Coverage (fail_under=78): `make coverage`
- Full check (lint + coverage): `make check`
- Bump patch version: `make bump-patch`
### Frontend Development
The terminal UI is built with a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web), which provides Ghostty's VT100 parser via WebAssembly with native theme/palette support. This replaces the original xterm.js dependency used in earlier versions.
Key improvements over xterm.js:
- Native theme colors passed directly to WASM (no runtime color remapping)
- Smaller bundle size (~0.67 MB vs ~1.16 MB)
- IME input support for CJK languages
- Better Unicode and complex script rendering
The pre-built bundle is committed to the repo, so users can `pip install` without needing Node.js.
To rebuild the frontend after modifying `terminal.ts`:
```bash
# Requires Bun (https://bun.sh)
bun install
bun run build
# Or simply:
make bundle
```
For development with auto-rebuild:
Frontend bundle tasks:
```bash
make build
make build-fast
make bundle-watch
```
### Notes
## Docker
- WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`.
- 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. `AltScreen` adds alternate screen buffer support, [CSI S/T scroll handling, and Ink partial clear expansion](docs/pyte-patches.md).
- Go runtime port is in `go/webterm` (entrypoint: `go/cmd/webterm`), using [go-te](https://github.com/rcarmo/go-te) for terminal state and screenshots.
- CPU stats are read directly from Docker socket using asyncio (no additional dependencies).
```bash
docker build -t webterm .
docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 webterm --docker-watch
```
## Requirements
- Python 3.9+
- Bun
- Linux or macOS
## License
MIT License - see [LICENSE](LICENSE) for details.
## Related Projects
- [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
- [pyte](https://github.com/selectel/pyte) - PYTE terminal emulator (used for SVG screenshots)
- [go-te](https://github.com/rcarmo/go-te) - Go VT terminal emulator used for the in-progress port
The image sets `WEBTERM_STATIC_PATH=/app/static` and serves assets from `go/webterm/static`.
The Dockerfile uses a minimal Alpine runtime stage and only installs `ca-certificates` plus `docker-cli`.
+42 -237
View File
@@ -1,257 +1,62 @@
# Architecture
This document describes the internal architecture of webterm.
## Overview
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.
`webterm` is a Go HTTP/WebSocket server that hosts one or more terminal sessions and renders screenshot/telemetry surfaces for a dashboard UI.
```
┌─────────────┐ ┌──────────────────────────────────────────────────┐
│ Browser │─────▶│ local_server.py
│ (ghostty- │◀─────│ (aiohttp web server) │
│ web) │ │ │
└─────────────┘ │ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ │ session_ │ │ terminal_session.py │
│ WebSocket │ │ manager.py │──│ (PTY + pyte emulator) │ │
└────────────▶│ └──────────────┘ └──────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ poller.py │ │ docker_stats.py │ │
(I/O thread) │ │ (CPU metrics via socket) │ │
│ └──────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────┘
Browser (terminal.js + ghostty-vt.wasm)
│ WS / HTTP / SSE
go/webterm/server.go (LocalServer)
├── session_manager.go (route/app/session registry)
├── terminal_session.go (PTY-backed local sessions)
├── docker_exec_session.go (Docker exec-backed sessions)
├── docker_watcher.go (container add/remove discovery)
├── docker_stats.go (CPU sampling + sparkline data)
└── svg_exporter.go (terminal snapshot -> SVG)
```
## Core Components
## Packages
### local_server.py
- `go/cmd/webterm`: CLI entrypoint
- `go/webterm`: server/runtime/domain logic
- `go/internal/terminalstate`: Go terminal emulator wrapper (`go-te`) used for screenshots
The main HTTP/WebSocket server built on aiohttp. Handles:
## Runtime data flow
- **HTTP routes**: Dashboard, screenshots, sparklines, SSE events, health checks
- **WebSocket connections**: Terminal I/O multiplexing with JSON protocol
- **Screenshot caching**: Time-based and change-based cache invalidation
- **SSE broadcasting**: Real-time activity notifications to dashboard
1. Browser connects to `/ws/{route_key}`.
2. `SessionManager` resolves or creates a session.
3. Session reads PTY output and updates:
- live WS stream (`stdout`)
- replay buffer (reconnect support)
- terminal-state tracker (`go-te`) for screenshots
4. Dashboard pulls `/screenshot.svg` and listens on `/events` for activity.
Key classes:
- `Server`: Main server class managing routes and session lifecycle
## Static assets
### session_manager.py
Assets live in `go/webterm/static`:
Manages the mapping between route keys and sessions:
- `js/terminal.ts` source
- `js/terminal.js` bundled client
- `js/ghostty-vt.wasm`
- `monospace.css`, icons, `manifest.json`
- **TwoWayDict**: Bidirectional mapping of RouteKey ↔ SessionID
- **Session creation**: Creates TerminalSession on demand
- **App registry**: Stores terminal configurations from manifest files
The server resolves static files from:
### terminal_session.py
1. `WEBTERM_STATIC_PATH` (if set)
2. local repository-relative fallbacks rooted at `go/webterm/static`
Manages a single terminal session:
## Docker integration
- **PTY management**: Fork/exec with pseudo-terminal
- **pyte emulator**: Uses `AltScreen` (patched pyte) for ANSI interpretation
- **Data pipeline**: C1 normalization → `expand_clear_sequences()``stream.feed()`
- **Replay buffer**: 64KB ring buffer for reconnection support
- **Resize handling**: Propagates window size changes to PTY
- **Compose mode** loads services from a compose manifest and creates tiles for services carrying `webterm-command`.
- **Watch mode** subscribes to Docker events and adds/removes tiles at runtime.
- `webterm-theme` controls tile theme; default theme applies if unset.
The pyte screen buffer provides character-level access for screenshots.
## Reliability notes
### poller.py
Background thread for non-blocking PTY I/O:
- **selector-based**: Uses `selectors.DefaultSelector` for efficient I/O
- **Async queues**: Bridges sync I/O thread to async main loop
- **Write queuing**: Handles backpressure for terminal input
### svg_exporter.py
Custom SVG renderer for terminal screenshots:
- **Per-character positioning**: Each character has explicit x coordinate
- **Box-drawing scaling**: Vertical 1.2x scale for line-height alignment
- **Color handling**: ANSI 16-color palette + 256-color + truecolor
- **Wide character support**: Proper column tracking for CJK characters
### docker_stats.py
Collects CPU metrics from Docker containers:
- **Unix socket client**: Direct HTTP-over-Unix-socket to Docker API
- **Compose awareness**: Filters containers by compose project label
- **History buffer**: 180 samples (30 min at 10s intervals)
- **Sparkline SVG**: Renders mini CPU graphs
## Data Flow
### Terminal I/O
```
Browser Server PTY
│ │ │
│──["stdin", "ls\n"]──────▶│ │
│ │────write(b"ls\n")───────▶│
│ │ │
│ │◀───read(output)──────────│
│◀─["stdout", "..."]───────│ │
```
### Screenshot Generation
```
Dashboard ──GET /screenshot.svg──▶ Server
TerminalSession
get_screen_state()
pyte.Screen
.buffer
svg_exporter.py
render_terminal_svg()
<svg>...</svg>
```
### SSE Activity Updates
```
Dashboard ──GET /events──▶ Server (SSE connection held open)
Terminal activity ───────────▶│
Broadcast to all SSE clients:
data: {"route_key": "...", "type": "activity"}
```
## Session Lifecycle
1. **Browser connects** to `/ws/{route_key}`
2. **SessionManager** looks up or creates session for route_key
3. **TerminalSession** forks PTY process, initializes pyte emulator
4. **Poller** registers PTY fd for I/O events
5. **WebSocket handler** bridges browser ↔ PTY via JSON messages
6. **On disconnect**: Session stays alive; browser can reconnect
7. **On reconnect**: Replay buffer restores recent output
## Configuration
### Manifest-based (--landing-manifest)
```yaml
- name: Display Name
slug: route-key
command: /path/to/command
```
### Compose-based (--compose-manifest)
Reads docker-compose.yaml, creates tiles for services with `webterm-command` label:
```yaml
services:
myservice:
labels:
webterm-command: docker exec -it myservice bash
```
## WebSocket Protocol
JSON-encoded messages over WebSocket:
| Direction | Message | Description |
|-----------|---------|-------------|
| Client→Server | `["stdin", "data"]` | Terminal input |
| Client→Server | `["resize", {"width": N, "height": M}]` | Window resize |
| Client→Server | `["ping", data]` | Keep-alive |
| Server→Client | `["stdout", "data"]` | Terminal output |
| Server→Client | `["pong", data]` | Keep-alive response |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Dashboard HTML or terminal redirect |
| `/ws/{route_key}` | WS | WebSocket terminal connection |
| `/screenshot.svg` | GET | SVG screenshot (query: `route_key`) |
| `/cpu-sparkline.svg` | GET | CPU sparkline (query: `container`) |
| `/events` | GET | SSE stream for activity updates |
| `/health` | GET | Health check |
## Key Design Decisions
### Why bundle ghostty-web directly?
We bundle a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web) for:
- **Native theme support** - Theme colors are passed directly to WASM, no runtime remapping needed
- **Production-tested VT100 parser** - Uses Ghostty's battle-tested parser via WebAssembly
- **Full configuration control** - fontFamily, scrollback, theme are configurable via CLI
- **IME support** - Proper input method support for CJK languages
- **Mobile support** - Custom keyboard handling for iOS Safari and Android
- **Smaller bundle** - ~0.67 MB (down from 1.16 MB with color patching)
- **11 built-in themes** - xterm, monokai, ristretto, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo
The pre-built `terminal.js` bundle is committed to the repo so users can `pip install` without needing Node.js/Bun.
### Why custom SVG exporter?
Rich's `export_svg()` had alignment issues with box-drawing characters and varied font rendering across browsers. The custom exporter:
- Positions each character individually for pixel-perfect alignment
- Scales box-drawing characters vertically to fill line height
- Uses explicit x coordinates instead of relying on font metrics
### Why pyte?
pyte provides a pure-Python terminal emulator that tracks screen state character-by-character, enabling:
- Screenshot generation without screen scraping
- Dirty tracking for efficient cache invalidation
- Full ANSI/VT100 escape sequence support
pyte 0.8.2 has gaps (no alternate screen buffer, no CSI S/T scroll), so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time to fill them — see [pyte-patches.md](pyte-patches.md) for details.
### Why session persistence?
Unlike traditional web terminals, sessions survive page refreshes:
- Replay buffer allows catching up on missed output
- SessionManager keeps sessions alive until explicit close
- Enables dashboard with multiple live terminal thumbnails
## File Structure
```
src/webterm/
├── alt_screen.py # pyte Screen subclass with alt buffer + SU/SD patches
├── cli.py # Click CLI entry point
├── config.py # Configuration parsing (YAML manifests)
├── local_server.py # Main HTTP/WebSocket server
├── session_manager.py # Session registry and routing
├── session.py # Abstract session interface
├── terminal_session.py # PTY-based terminal session
├── poller.py # Async I/O polling thread
├── svg_exporter.py # Terminal→SVG renderer
├── docker_stats.py # Docker CPU metrics collector
├── exit_poller.py # Graceful shutdown handling
├── identity.py # Session ID generation
├── slugify.py # URL-safe slug generation
├── types.py # Type aliases
├── constants.py # Platform constants
└── static/
├── monospace.css # Font stack CSS variables
└── js/
├── terminal.ts # ghostty-web client source (TypeScript)
├── terminal.js # Pre-built bundle (committed)
└── ghostty-vt.wasm # Ghostty VT100 parser (WebAssembly)
```
- WebSocket writes are serialized through a sender queue.
- Session-manager maps are lock-protected and race-tested.
- Replay buffers are bounded to avoid unbounded memory growth.
-890
View File
@@ -1,890 +0,0 @@
# Roadmap: Migration to ghostty-web
This document outlines the completed migration from xterm.js/textual-serve to ghostty-web.
## Status: ✅ Complete (v1.0.0)
The migration has been completed and merged to `main`.
### What Was Done
- [x] **Phase 1: Tooling Setup** - Added `package.json`, `bunfig.toml`, Makefile targets
- [x] **Phase 2: Terminal Client** - Created `terminal.ts` with full WebSocket protocol
- [x] **Phase 3: Server Integration** - Updated HTML template, removed monkey-patch
- [x] **Phase 4: Configuration** - Added CLI options for theme, font-family, font-size
- [x] **Phase 5: Remove Dependency** - Dropped `textual-serve` from pyproject.toml
- [x] **Phase 6: Documentation** - Updated README.md and ARCHITECTURE.md
- [x] **Phase 7: Mobile Support** - Added hidden textarea for iOS Safari keyboard
- [x] **Phase 8: Native Theme Support** - Upgraded to patched ghostty-web with WASM palette support
### Key Outcomes
| Metric | Before (xterm.js) | After (ghostty-web 0.4.0) |
|--------|-------------------|---------------------------|
| textual-serve dependency | Required | ❌ Removed |
| Terminal engine | xterm.js 6.0 | ghostty-web (Ghostty VT parser via WASM) |
| Theme handling | Runtime color remapping | Native WASM palette support |
| Scrollback history | 0 (none) | 1000 (configurable) |
| Theme configuration | None | 11 built-in themes via `--theme` |
| Font configuration | Monkey-patch workaround | `--font-family` and `--font-size` CLI options |
| Mobile Safari | No keyboard | ✅ Hidden textarea for keyboard input |
| IME support | Limited | ✅ Full CJK input method support |
| Bundle size | 1.16 MB | 0.67 MB |
### ghostty-web Fork
We use a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web) that adds:
- Native theme/palette support at the WASM level via `buildWasmConfig()`
- Theme colors passed directly to `createTerminal()` config
- No runtime color remapping needed
- IME input fixes for CJK languages
### Files Changed
```
Added:
package.json # ghostty-web + TypeScript
bunfig.toml # Bun configuration
tsconfig.json # TypeScript configuration
src/.../static/js/terminal.ts # TypeScript source
src/.../static/js/terminal.js # Pre-built bundle (committed)
src/.../static/js/ghostty-vt.wasm # Ghostty VT100 parser
Modified:
pyproject.toml # Removed textual-serve dependency, version 1.0.0
Makefile # Added bundle/bundle-watch/bump-patch targets
.gitignore # Added node_modules/
src/.../cli.py # Added --theme, --font-family, --font-size
src/.../local_server.py # Pass theme/font config to HTML template
docs/ARCHITECTURE.md # Updated for ghostty-web
docs/ROADMAP.md # Migration complete
README.md # Updated documentation
```
### For Users
No action required. The pre-built `terminal.js` bundle is committed to the repo:
```bash
pip install webterm
# or
pip install git+https://github.com/rcarmo/webterm.git
```
Works without needing Node.js or Bun.
### For Developers
To modify the frontend:
```bash
# Install Bun (https://bun.sh)
curl -fsSL https://bun.sh/install | bash
# Install dependencies and build
make bundle
# Or watch for changes during development
make bundle-watch
# Bump patch version
make bump-patch
```
---
## Background Analysis
The sections below document the original analysis that led to this migration.
### What textual-serve Provided
| Asset | Size | What We Used | Required? |
|-------|------|--------------|-----------|
| `static/js/textual.js` | 502 KB | xterm.js + WebSocket client | **Yes** |
| `static/css/xterm.css` | 4.6 KB | Terminal styling | **Yes** |
| `static/fonts/RobotoMono*.ttf` | 381 KB | Roboto Mono font | No (we override font) |
| `static/images/background.png` | 58 KB | Background image | No |
| **Total** | **948 KB** | | |
### Why We Switched to ghostty-web
| Benefit | Impact |
|---------|--------|
| **Production-tested VT100 parser** | Ghostty's parser handles edge cases correctly |
| **xterm.js API compatibility** | Easy migration, familiar API |
| **Full configuration control** | Theme, font, scrollback via CLI |
| **Mobile Safari support** | Hidden textarea triggers keyboard |
| **Modern features** | RGB colors, better cursor handling |
### WebSocket Protocol (Fully Compatible)
The protocol is simple JSON arrays. Our server already implements this:
| Direction | Message | Description |
|-----------|---------|-------------|
| Client → Server | `["stdin", "data"]` | Terminal input |
| Client → Server | `["resize", {width: N, height: M}]` | Window resize |
| Client → Server | `["ping", data]` | Keep-alive |
| Server → Client | `["stdout", "data"]` | Terminal output (text) |
| Server → Client | Binary frame | Terminal output (binary) |
| Server → Client | `["pong", data]` | Keep-alive response |
### Current Workarounds
1. **Font override**: Canvas monkey-patch in HTML to replace hardcoded font family
2. **No scrollback**: Users cannot scroll back through terminal history
---
## Tradeoffs Analysis
### Option A: Keep textual-serve Dependency
| Pros | Cons |
|------|------|
| Zero build tooling | Hardcoded font requires workaround |
| Automatic updates via pip | No scrollback (scrollback: 0) |
| Maintained by Textualize | No theme customization |
| | Carries unused fonts/images (381 KB) |
| | Tied to textual-serve release cycle |
| | Unknown xterm.js version (likely 5.x) |
### Option B: Bundle xterm.js 6.0 Directly
| Pros | Cons |
|------|------|
| Full configuration control | Requires Bun toolchain |
| Scrollback history support | ~150-200 KB bundle to maintain |
| Custom themes/colors | Must track xterm.js updates |
| Latest xterm.js 6.0 features | Initial setup effort (2-3 days) |
| Smaller bundle (no unused fonts) | |
| Can drop textual-serve dependency | |
### xterm.js 6.0 Features We'd Gain
| Feature | Benefit |
|---------|---------|
| Synchronized output (DEC 2026) | Smoother rapid output rendering |
| Ligature support | Better programming font rendering |
| Progress addon | Visual progress indicators |
| Shadow DOM support | Better CSS encapsulation |
| ESM support | Modern module loading |
| Performance improvements | Faster search, less memory |
| OSC 52 clipboard | Secure clipboard from terminal |
---
## Implementation Plan (Completed)
### Phase 1: Tooling Setup ✅
**Goal**: Establish Bun-based build pipeline
```
src/webterm/
├── static/
│ ├── js/
│ │ └── terminal.ts # New: our xterm wrapper
│ ├── css/
│ │ └── xterm.css # Copied from xterm.js package
│ └── monospace.css # Existing
├── package.json # New: npm dependencies
└── bunfig.toml # New: Bun configuration
```
**Tasks**:
- [x] Create `package.json` with xterm.js 6.0 dependencies
- [x] Create `bunfig.toml` for build configuration
- [x] Add `Makefile` targets: `make bundle`, `make bundle-watch`
- [x] Add `.gitignore` entries for `node_modules/`
- [x] Document Bun installation in README
**package.json** (final):
```json
{
"name": "webterm-frontend",
"private": true,
"type": "module",
"dependencies": {
"@xterm/xterm": "^6.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-clipboard": "^0.2.0"
},
"devDependencies": {
"typescript": "^5.7.0"
},
"scripts": {
"build": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --watch --target=browser"
}
}
```
### Phase 2: Terminal Client Implementation ✅
**Goal**: Create `terminal.ts` that replicates textual.js functionality
**Tasks**:
- [x] Implement Terminal wrapper class
- [x] WebSocket connection with reconnection logic
- [x] Message protocol handling (stdin, resize, ping/pong)
- [x] Addon initialization (fit, webgl, canvas, unicode11, web-links, clipboard)
- [x] Configurable options via data attributes or window config
See `src/webterm/static/js/terminal.ts` for the full implementation (~230 lines).
### Phase 3: Server Integration ✅
**Goal**: Update local_server.py to use new bundle
**Tasks**:
- [x] Update HTML template to load our bundle instead of textual.js
- [x] Remove canvas monkey-patch workaround
- [x] Add data attributes for scrollback, theme configuration
- [x] Copy xterm.css to our static folder
- [x] Update static file routes
### Phase 4: Configuration Support ✅
**Goal**: Make terminal appearance configurable
**Tasks**:
- [x] Pass config to HTML template via data attributes (`data-scrollback`, `data-font-size`)
- [ ] Add terminal config to CLI (--scrollback, --font-family) - *Future enhancement*
- [ ] Add terminal config to TOML manifest files - *Future enhancement*
### Phase 5: Remove textual-serve Dependency ✅
**Goal**: Eliminate dependency once our bundle is stable
**Tasks**:
- [x] Remove `textual-serve` from pyproject.toml dependencies
- [x] Update ARCHITECTURE.md to document new frontend
- [x] Update README.md with build instructions
- [x] Commit pre-built bundle so users don't need Bun
### Phase 6: Testing & Polish
**Goal**: Ensure reliability across browsers
**Tasks**:
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
- [ ] Mobile browser testing (iOS Safari, Chrome Android)
- [x] WebGL fallback to Canvas (implemented in terminal.ts)
- [x] Reconnection logic (implemented with exponential backoff)
- [ ] Performance comparison vs textual.js
- [x] Bundle size: 560 KB (acceptable for full xterm.js + addons)
---
## Build Integration (Reference)
### Makefile Additions
```makefile
# Frontend build
.PHONY: bundle bundle-watch bundle-clean
bundle: node_modules
bun run build
bundle-watch: node_modules
bun run watch
bundle-clean:
rm -rf node_modules src/webterm/static/js/terminal.js
node_modules: package.json
bun install
```
### Dockerfile Changes
```dockerfile
# Add Bun for frontend build
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# Build frontend
COPY package.json bunfig.toml ./
RUN bun install
COPY src/webterm/static/js/terminal.ts src/webterm/static/js/
RUN bun run build
```
### CI/CD Considerations
- Pre-commit hook to verify `terminal.js` matches `terminal.ts`
- Or: commit built bundle to repo (simpler for users without Bun)
- GitHub Actions step to build and verify bundle
---
## Risk Mitigation
| Risk | Mitigation |
|------|------------|
| xterm.js 6.0 breaking changes | Pin exact version, test thoroughly |
| Bun compatibility issues | Fall back to esbuild if needed |
| WebSocket protocol mismatch | Keep protocol identical to textual.js |
| Performance regression | Benchmark before/after, keep WebGL |
| Missing addon features | Test each addon explicitly |
---
## Timeline Estimate
| Phase | Effort | Dependencies |
|-------|--------|--------------|
| Phase 1: Tooling Setup | 0.5 days | None |
| Phase 2: Terminal Client | 1-2 days | Phase 1 |
| Phase 3: Server Integration | 0.5 days | Phase 2 |
| Phase 4: Configuration | 0.5 days | Phase 3 |
| Phase 5: Remove Dependency | 0.5 days | Phase 4 |
| Phase 6: Testing | 1 day | Phase 5 |
| **Total** | **4-5 days** | |
---
## Decision Checkpoints
1. **After Phase 2**: Verify terminal.ts works in isolation before integrating
2. **After Phase 3**: Side-by-side comparison with textual.js
3. **After Phase 5**: Confirm no regressions before removing dependency
4. **After Phase 6**: Final sign-off for release
---
## Success Criteria
- [ ] Terminal renders correctly in Chrome, Firefox, Safari
- [x] Scrollback history works (configurable limit)
- [x] Custom fonts load without workarounds
- [x] WebGL rendering enabled with Canvas fallback
- [x] Bundle size: 560 KB (larger than target due to full addon suite, but acceptable)
- [x] No textual-serve dependency in pyproject.toml
- [x] All existing tests pass (302 tests)
- [x] Documentation updated
---
---
# Future: Go Reimplementation
This section analyzes what it would take to reimplement webterm in Go for lighter deployment.
## Status: 📋 Planning
Not yet started. This would be a separate project (`webterm-go`) providing a lightweight alternative.
## Executive Summary
**Most functionality can be reimplemented in Go** with mature libraries. The main challenge is the terminal emulator (pyte equivalent) - GoPyte exists but is less battle-tested than Python's pyte. Benefits would be a single static binary, lower memory footprint, and better concurrency.
---
## Component Mapping
| Python Component | Go Equivalent | Library | Maturity |
|-----------------|---------------|---------|----------|
| **aiohttp** (HTTP/WS server) | net/http + websocket | `gorilla/websocket` or `nhooyr.io/websocket` | ⭐⭐⭐⭐⭐ Excellent |
| **pyte** (terminal emulator) | GoPyte | `github.com/scottpeterman/gopyte` | ⭐⭐⭐ Good |
| **PTY handling** | go-pty | `github.com/aymanbagabas/go-pty` | ⭐⭐⭐⭐ Very Good |
| **asyncio** (concurrency) | goroutines/channels | stdlib | ⭐⭐⭐⭐⭐ Native |
| **SSE** | Custom handler | stdlib `net/http` | ⭐⭐⭐⭐ Simple |
| **Docker stats** | Docker SDK | `github.com/docker/docker/client` | ⭐⭐⭐⭐⭐ Official |
| **SVG generation** | SVGo | `github.com/ajstarks/svgo` | ⭐⭐⭐⭐⭐ Mature |
| **YAML parsing** | yaml.v3 | `gopkg.in/yaml.v3` | ⭐⭐⭐⭐⭐ Standard |
| **CLI** | cobra | `github.com/spf13/cobra` | ⭐⭐⭐⭐⭐ Standard |
---
## 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 webterm. Where GoPyte details are unclear, we call them out explicitly as validation items.
### Feature Matrix (Capture-Relevant)
| Capability | pyte (Python) | GoPyte (Go) | Capture Impact / Gaps |
|-----------|---------------|-------------|------------------------|
| **Terminal standards** | VTXXX/ANSI (VT100-style) | VT100/VT220/ANSI (claims) | Verify DEC private modes, OSC handling. |
| **Screen buffer** | Screen + HistoryScreen (cells with attrs) | Screen buffer (claims) | Data model differences may affect attrs. |
| **Alt screen** | Supported | Supported (claims) | Full-screen apps depend on correct switching. |
| **Scrollback/history** | HistoryScreen (configurable) | Built-in scrollback (claims) | Semantics may differ; check memory cost. |
| **Resize behavior** | Resizes + cursor + dirty state | Resizes + content (claims) | Must preserve content on resize. |
| **SGR attributes** | Bold/underline/reverse/color | SGR attrs (claims) | Verify italics, faint, strikethrough, blink. |
| **Underline styles** | Basic underline | Unknown | Double/curly underline may be missing. |
| **Color depth** | ANSI + 256 + (truecolor in practice) | ANSI + 256? (verify) | Truecolor required for accurate screenshots. |
| **Default colors** | SGR 39/49 respected | Unknown | Default fg/bg must be stable. |
| **Reverse video** | Supported | Unknown | Affects screenshot accuracy. |
| **DEC special graphics** | Supported (line drawing) | Unknown | Box-drawing is critical for TUIs. |
| **Character sets (G0/G1)** | Supported | Unknown | Needed for legacy line-drawing. |
| **Scroll regions** | Supported | Unknown | Needed for curses-style UI. |
| **Insert/delete (IL/DL/ICH/DCH)** | Supported | Unknown | Affects screen fidelity during updates. |
| **Tabs / tab stops** | Supported | Unknown | UI alignment depends on tabs. |
| **Autowrap/origin modes** | Supported | Unknown | Impacts cursor positioning and layout. |
| **Cursor save/restore** | Supported | Unknown | Common in TUIs and prompts. |
| **Cursor visibility/style** | Basic visibility | Unknown | Useful for accurate snapshots. |
| **Erase semantics (ED/EL/ECH)** | Supported | Unknown | Correct clearing is vital for screenshots. |
| **Selective erase (DECSEL/DECSERA)** | Unknown | Unknown | Might matter for some TUIs. |
| **Unicode width** | wcwidth-style width | go-runewidth | Width differences can misalign SVG. |
| **Combining marks / ZWJ** | Unicode-aware | runewidth-based | Emoji sequences may differ in width. |
| **Dirty tracking** | Exposes dirty set / DiffScreen | Unknown | Needed for efficient screenshot caching. |
| **Images (sixel/kitty)** | Not supported | Not supported | Not required, but a capture limitation. |
| **Performance** | Python, moderate | Go, claims high throughput | Benchmark under heavy output. |
| **API stability** | Mature, widely used | Newer, smaller ecosystem | Risk of breaking changes. |
| **Test maturity** | Established | Claims high coverage | Must run our own parity suite. |
### Unicode & Emoji Handling (Critical for SVG Capture)
**pyte**
- Uses Python Unicode handling plus width calculation (wcwidth-style).
- Generally robust for CJK and emoji, though edge cases exist (ZWJ, variation selectors).
**GoPyte**
- Uses `go-runewidth` for width calculation.
- Width differences vs wcwidth can shift glyph placement in SVG output.
**Capture Impact**
- Width mismatches change x-coordinates -> visually incorrect screenshots.
- Emoji sequences (ZWJ, VS16) may render as 1 cell in one emulator and 2 in another.
- Must validate CJK + emoji + combining marks across sample workloads.
### Performance & Memory (Capture-Heavy Workloads)
**pyte**
- Pure Python; adequate for typical workloads.
- Predictable, but slower under heavy output.
**GoPyte**
- Go implementation; upstream claims high throughput.
- Performance depends on allocation strategy and screen model.
**Action**: Benchmark with fast-output scenarios, large scrollback, and frequent screenshots.
### Required Features for webterm Capture
We depend on the emulator for **accurate snapshot state**, not just live display:
- Stable **screen buffer** with per-cell fg/bg/bold/underline/reverse.
- Correct **cursor position** after control sequences and resizes.
- Accurate **alt screen** behavior for full-screen TUIs.
- Consistent **Unicode width** for precise SVG positioning.
- **Dirty tracking or diffability** to avoid re-rendering unchanged screens.
- Reliable **color mapping** (ANSI 16 + 256 + truecolor).
- Correct **DEC line drawing** (box-drawing characters).
- Correct **erase semantics** (ED/EL/ECH) to avoid stale cells.
If any of these are missing, screenshots will be wrong or expensive to compute.
### Capture-Enhancing (Nice-to-Have) Features
These arent required for parity, but would improve capture quality or UX:
- **OSC 8 hyperlink metadata** to annotate clickable URLs in SVG output.
- **OSC 52 clipboard** metadata for copy workflows.
- **Underline styles** (double/curly) for richer text attributes.
- **Cursor style/shape** metadata for accurate snapshots.
- **Grapheme cluster awareness** (emoji sequences treated as single cells).
- **Structured diffs** from emulator (explicit dirty regions instead of full screen).
### Known Gaps / Validation Checklist
Before relying on GoPyte for parity, verify:
- [ ] Full-screen app behavior (vim, htop, less) with alt screen.
- [ ] DEC line drawing / special graphics set (box-drawing correctness).
- [ ] Character set switching (G0/G1) for legacy graphics.
- [ ] Scroll regions (DECSTBM) and origin mode behavior.
- [ ] Insert/delete line/char (IL/DL/ICH/DCH) correctness.
- [ ] Tab stops and tab clear (alignment in TUIs).
- [ ] SGR coverage: bold/underline/italic/reverse + 256/truecolor.
- [ ] Underline styles, faint, strikethrough, blink.
- [ ] Default colors (SGR 39/49) and reverse video.
- [ ] Unicode width parity with pyte (emoji + CJK samples).
- [ ] Cursor save/restore (DECSC/DECRC) and visibility toggles.
- [ ] Erase semantics (ED/EL/ECH) and autowrap correctness.
- [ ] Resize correctness (content preservation, cursor placement).
- [ ] Performance at high output rates (100k+ lines, low latency).
### Integration Implications for webterm
- **Screen buffer mapping**: GoPyte cells must map to our SVG exporter schema (fg/bg/bold/underline/reverse).
- **Dirty tracking**: pyte exposes dirty state; GoPyte may need explicit diff tracking.
- **Color translation**: Ensure SGR parsing aligns with our ANSI palette and truecolor handling.
- **Replay buffer**: Replay must stay consistent with screen state for accurate screenshots.
### Alternatives (Brief)
If GoPyte does not meet parity, consider:
- **govte** (`github.com/cliofy/govte`): More comprehensive xterm-like parser/buffer, less documented.
- **vito/vt100**: Lightweight VT100 emulator, limited scrollback/alt-screen.
- **xyproto/vt100**: TUI canvas focus, not a drop-in emulator.
These alternatives still need capture parity validation.
## What We'd Gain
| Benefit | Impact |
|---------|--------|
| **Single static binary** | No Python/pip dependency, simpler deployment |
| **Lower memory** | ~10-20MB vs ~50-100MB for Python |
| **Better concurrency** | Goroutines vs asyncio - more intuitive |
| **Faster startup** | Instant vs Python interpreter load |
| **Cross-compilation** | Easy builds for Linux/macOS/Windows/ARM |
| **Smaller Docker image** | ~20MB vs ~200MB+ with Python |
## What We'd Lose
| Loss | Impact |
|------|--------|
| **Textual app support** | Removed in webterm |
| **Rapid prototyping** | Go requires more boilerplate |
| **pyte maturity** | GoPyte is less proven |
---
## Required Go Dependencies
```go
// go.mod
module github.com/rcarmo/webterm-go
go 1.22
require (
// HTTP/WebSocket
github.com/gorilla/websocket v1.5.1
// Terminal emulation
github.com/scottpeterman/gopyte v0.1.0
// PTY handling
github.com/aymanbagabas/go-pty v0.2.2
// Docker stats
github.com/docker/docker v25.0.0
// SVG generation
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
// CLI
github.com/spf13/cobra v1.8.0
// YAML parsing
gopkg.in/yaml.v3 v3.0.1
)
```
---
## Implementation Plan
### Phase 1: Project Setup & Core Server (2 days)
**Goal**: Basic HTTP server with WebSocket support
**Tasks**:
- [ ] Initialize Go module with dependencies
- [ ] Create basic HTTP server with routing
- [ ] Implement WebSocket upgrade handler
- [ ] Port JSON message protocol (stdin, resize, ping/pong)
- [ ] Add graceful shutdown handling
**Files**:
```
cmd/
webterm/
main.go # Entry point
internal/
server/
server.go # HTTP server
websocket.go # WebSocket handler
routes.go # Route definitions
```
### Phase 2: PTY Session Management (2 days)
**Goal**: Spawn and manage terminal sessions
**Tasks**:
- [ ] Integrate go-pty for PTY creation
- [ ] Implement session lifecycle (create, resize, close)
- [ ] Build session manager with route mapping
- [ ] Add replay buffer for reconnection
- [ ] Handle concurrent session access
**Files**:
```
internal/
session/
manager.go # Session registry
terminal.go # PTY session wrapper
buffer.go # Replay ring buffer
```
### Phase 3: Terminal Emulation (2 days)
**Goal**: Parse ANSI sequences for screen state
**Tasks**:
- [ ] Integrate GoPyte terminal emulator
- [ ] Feed PTY output through emulator
- [ ] Extract screen buffer for screenshots
- [ ] Implement dirty tracking for cache invalidation
- [ ] Handle resize events
**Files**:
```
internal/
terminal/
emulator.go # GoPyte wrapper
screen.go # Screen buffer access
```
### Phase 4: SVG Screenshot Generation (1.5 days)
**Goal**: Generate terminal screenshots as SVG
**Tasks**:
- [ ] Port character positioning logic from Python
- [ ] Implement ANSI color palette (16 + 256 + truecolor)
- [ ] Handle box-drawing character scaling
- [ ] Add screenshot caching with ETag support
- [ ] Implement cache TTL backoff
**Files**:
```
internal/
screenshot/
svg.go # SVG renderer
colors.go # ANSI color handling
cache.go # Screenshot cache
```
### Phase 5: Dashboard & SSE (1.5 days)
**Goal**: Landing page with live updates
**Tasks**:
- [ ] Embed static assets (HTML, CSS, xterm.js bundle)
- [ ] Implement SSE endpoint for activity notifications
- [ ] Port dashboard HTML template
- [ ] Add tile rendering with screenshots
**Files**:
```
internal/
dashboard/
handler.go # Dashboard HTTP handler
sse.go # Server-Sent Events
static/
embed.go # Embedded assets
```
### Phase 6: Docker Stats (1 day)
**Goal**: CPU sparklines for compose mode
**Tasks**:
- [ ] Integrate Docker SDK client
- [ ] Implement container stats polling
- [ ] Calculate CPU percentage from deltas
- [ ] Generate sparkline SVGs
- [ ] Filter by compose project label
**Files**:
```
internal/
docker/
stats.go # Stats collector
sparkline.go # SVG sparkline
```
### Phase 7: CLI & Configuration (1 day)
**Goal**: Feature-complete CLI
**Tasks**:
- [ ] Implement Cobra CLI with flags
- [ ] Parse YAML landing manifests
- [ ] Parse Docker Compose manifests
- [ ] Add version command
- [ ] Environment variable support
**Files**:
```
cmd/
webterm/
main.go # CLI entry point
internal/
config/
config.go # Configuration types
manifest.go # YAML parsing
```
### Phase 8: Testing & Polish (2 days)
**Goal**: Production-ready release
**Tasks**:
- [ ] Unit tests for core components
- [ ] Integration tests for WebSocket protocol
- [ ] Cross-browser testing
- [ ] Build scripts for multiple platforms
- [ ] Docker image (FROM scratch)
- [ ] Documentation
**Files**:
```
Makefile # Build targets
Dockerfile # Multi-stage build
README.md # Usage docs
```
---
## Effort Summary
| Phase | Component | Days |
|-------|-----------|------|
| 1 | Project Setup & Core Server | 2 |
| 2 | PTY Session Management | 2 |
| 3 | Terminal Emulation | 2 |
| 4 | SVG Screenshot Generation | 1.5 |
| 5 | Dashboard & SSE | 1.5 |
| 6 | Docker Stats | 1 |
| 7 | CLI & Configuration | 1 |
| 8 | Testing & Polish | 2 |
| **Total** | | **13 days** |
---
## File Structure (Final)
```
webterm-go/
├── cmd/
│ └── webterm/
│ └── main.go
├── internal/
│ ├── config/
│ │ ├── config.go
│ │ └── manifest.go
│ ├── dashboard/
│ │ ├── handler.go
│ │ └── sse.go
│ ├── docker/
│ │ ├── sparkline.go
│ │ └── stats.go
│ ├── screenshot/
│ │ ├── cache.go
│ │ ├── colors.go
│ │ └── svg.go
│ ├── server/
│ │ ├── routes.go
│ │ ├── server.go
│ │ └── websocket.go
│ ├── session/
│ │ ├── buffer.go
│ │ ├── manager.go
│ │ └── terminal.go
│ ├── static/
│ │ └── embed.go
│ └── terminal/
│ ├── emulator.go
│ └── screen.go
├── static/
│ ├── css/
│ │ └── xterm.css
│ └── js/
│ └── terminal.js
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
└── README.md
```
---
## Build & Release
### Makefile Targets
```makefile
.PHONY: build build-all test clean
BINARY := webterm
VERSION := $(shell git describe --tags --always)
LDFLAGS := -s -w -X main.version=$(VERSION)
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/webterm
build-all:
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/webterm
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-arm64 ./cmd/webterm
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/webterm
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/webterm
test:
go test -v ./...
clean:
rm -rf bin/
```
### Minimal Docker Image
```dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o webterm ./cmd/webterm
FROM scratch
COPY --from=builder /app/webterm /webterm
ENTRYPOINT ["/webterm"]
```
**Result**: ~15-20MB Docker image vs ~200MB+ for Python version.
---
## Decision Criteria
Proceed with Go reimplementation if:
- [ ] Deployment size is critical (embedded, edge, IoT)
- [x] No need for Textual app support
- [ ] Want single-binary distribution
- [ ] Memory constraints matter
Keep Python version if:
- [ ] Need Textual app support
- [ ] Rapid iteration is priority
- [ ] Team more familiar with Python
- [ ] Current deployment size is acceptable
---
## References
- GoPyte: https://github.com/scottpeterman/gopyte
- go-pty: https://github.com/aymanbagabas/go-pty
- Gorilla WebSocket: https://github.com/gorilla/websocket
- Docker Go SDK: https://pkg.go.dev/github.com/docker/docker/client
- SVGo: https://github.com/ajstarks/svgo
- Cobra CLI: https://github.com/spf13/cobra
-91
View File
@@ -1,91 +0,0 @@
# pyte Patches
webterm uses [pyte](https://github.com/selectel/pyte) (v0.8.2) as a headless terminal emulator to capture screen state for SVG screenshots. pyte doesn't implement several standard escape sequences, so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time and provides additional methods.
## Patch 1: CSI S / CSI T — Scroll Up & Down (SU/SD)
**Status:** Primary fix for ghost content in screenshots.
### Problem
When `TERM=xterm-256color`, tmux uses `CSI n S` (Scroll Up) to shift content upward — for example, when Ink-based CLI apps (GitHub Copilot CLI) issue `/clear`. pyte does not implement CSI S or CSI T, silently ignoring the sequences. Old content remains in the screen buffer, appearing as ghost lines in SVG screenshots.
Real terminals (Ghostty, iTerm2, etc.) handle CSI S/T correctly, so the issue only manifests in pyte-rendered screenshots.
### Root cause
pyte's CSI dispatch table has no entry for `"S"` (SU — Scroll Up) or `"T"` (SD — Scroll Down). The `xterm-256color` terminfo entry includes `indn=\E[%p1%dS` and `rin=\E[%p1%dT`, so tmux sends these when the outer terminal advertises support. With simpler TERM values (e.g. `xterm-color`), tmux falls back to DECSTBM + index, which pyte handles correctly.
### Fix
At module load time, `alt_screen.py` patches pyte's dispatch tables and event set:
```python
pyte.ByteStream.csi["S"] = "scroll_up"
pyte.ByteStream.csi["T"] = "scroll_down"
pyte.Stream.csi["S"] = "scroll_up"
pyte.Stream.csi["T"] = "scroll_down"
pyte.Stream.events = pyte.Stream.events | frozenset(["scroll_up", "scroll_down"])
```
`AltScreen` implements `scroll_up(count)` and `scroll_down(count)`, which shift buffer lines within the current scroll region (respecting DECSTBM margins) and blank the vacated rows.
### Verification
```python
from webterm.alt_screen import AltScreen
import pyte
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Fill screen with content
for i in range(24):
stream.feed(f"Line {i}\r\n".encode())
# CSI 6 S — scroll up 6 lines (top 6 lines lost, bottom 6 blanked)
stream.feed(b"\x1b[6S")
# Lines 6-23 shifted to rows 0-17, rows 18-23 are blank
assert screen.display[0].strip() == "Line 6"
assert screen.display[17].strip() == "Line 23"
assert screen.display[18].strip() == ""
```
## Patch 2: Alternate Screen Buffer (DECSET/DECRST 1049)
**Status:** Core feature, implemented since v1.0.
### Problem
pyte doesn't implement private mode 1049 (or 47/1047/1048) for alternate screen buffers. Full-screen programs (tmux, vim, less) switch to an alternate buffer on entry and restore the main buffer on exit. Without this, exiting vim inside tmux would leave vim's screen content overlaid on the shell prompt in screenshots.
### Fix
`AltScreen` overrides `set_mode()` and `reset_mode()` to intercept private modes 47/1047/1048/1049. On entry, it deep-copies the current buffer and cursor; on exit, it restores them. The saved buffer is invalidated on resize.
## Patch 3: Ink Partial Clear Expansion (best-effort)
**Status:** Secondary defense; largely superseded by Patch 1.
### Problem
Ink-based CLI frameworks erase their previous output using repeated `EL2 + CUU1` (erase line + cursor up) pairs. When `/clear` resets Ink's internal line counter, the next frame erases fewer lines than needed. In a real terminal the old content has scrolled into the scrollback buffer, but pyte's fixed-size screen retains it.
### Fix
`AltScreen.expand_clear_sequences(data)` pre-processes incoming bytes before feeding to pyte. It detects runs of 3+ `EL2+CUU1` pairs that don't reach row 0 and extends them to cover all lines above the cursor.
Both `TerminalSession._update_screen()` and `DockerExecSession._update_screen()` call this method after C1 normalization and before `stream.feed()`.
### Why this is safe
- Only triggers on runs of **3 or more** `EL2+CUU1` pairs (normal editing uses 12).
- Only adds erase operations for lines that would already be empty in a real terminal.
- Short runs (< 3 pairs) and runs that already reach row 0 are left unchanged.
## Related
- `docs/tmux-da-response-filtering.md` — filtering Device Attributes responses from tmux.
- `docs/ROADMAP.md` — future Go reimplementation would replace pyte entirely.
- `WEBTERM_SCREENSHOT_FORCE_REDRAW` env var — sends SIGWINCH to force app redraw before screenshots.
-51
View File
@@ -1,51 +0,0 @@
# Fix for "1;10;0c" Display Issue in tmux
## Problem
When entering or refreshing a webterm session running tmux, the text "1;10;0c"
appears on the screen. This is a terminal Device Attributes (DA) response that
should be filtered out.
## Root Cause
1. **Tmux sends a DA2 query** (`ESC[>c`) when it initializes to detect terminal capabilities
2. The terminal responds with `ESC[>1;10;0c` (DA2 response format)
3. The filtering code only handled DA1 responses (`ESC[?...c`) but **not DA2** (`ESC[>...c`)
4. The unfiltered DA2 response appeared as visible text "1;10;0c" in the replay buffer
## Solution
Updated the DA response filtering patterns in three files to handle all DA variants:
- **DA1** (Primary): `ESC[?...c` - already handled ✓
- **DA2** (Secondary): `ESC[>...c` - **NOW FIXED**
- **DA3** (Tertiary): `ESC[=...c` - added for completeness ✓
### Changed Files
1. `src/webterm/terminal_session.py`
2. `src/webterm/docker_exec_session.py`
3. `src/webterm/local_server.py`
### Pattern Changes
**Before:**
```python
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:\?[\d;]*)?)?$")
```
**After:**
```python
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c")
DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:[?>=][\d;]*)?)?$")
```
## Testing
- All 342 existing tests pass ✓
- Verified the pattern matches DA1, DA2, and DA3 responses ✓
- Confirmed partial buffering works for all variants ✓
## Technical Details
Device Attributes queries/responses:
- **DA1**: `ESC[c``ESC[?1;10;0c` (terminal capabilities)
- **DA2**: `ESC[>c``ESC[>1;10;0c` (terminal version - **sent by tmux**)
- **DA3**: `ESC[=c``ESC[=1;0c` (rarely used)
The fix ensures all three variants are filtered from both live output and
replay buffers when clients reconnect.
+2 -16
View File
@@ -147,14 +147,7 @@ func (s *DockerExecSession) handleOutput(data []byte) {
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(filtered) == 0 {
return
}
s.replay.Add(filtered)
if tracker != nil {
_ = tracker.Feed(filtered)
}
connector.OnData(filtered)
dispatchSessionOutput(filtered, tracker, s.replay, connector)
}
func (s *DockerExecSession) createExec() (string, error) {
@@ -317,14 +310,7 @@ func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot {
tracker := s.tracker
width, height := s.width, s.height
s.mu.RUnlock()
if tracker == nil {
return terminalstate.Snapshot{
Width: width,
Height: height,
Buffer: make([][]terminalstate.Cell, height),
}
}
return tracker.Snapshot()
return snapshotFromTracker(tracker, width, height)
}
func (s *DockerExecSession) UpdateConnector(connector SessionConnector) {
+2
View File
@@ -67,6 +67,8 @@ func (d *DockerStatsCollector) Start(serviceNames []string) {
d.mu.Unlock()
return
}
d.stopCh = make(chan struct{})
d.doneCh = make(chan struct{})
d.serviceList = append([]string{}, serviceNames...)
d.running = true
d.mu.Unlock()
+8
View File
@@ -93,3 +93,11 @@ func TestDockerStatsHelperConversions(t *testing.T) {
t.Fatalf("max mismatch: %d", got)
}
}
func TestDockerStatsCollectorCanRestart(t *testing.T) {
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
collector.Start([]string{"svc"})
collector.Stop()
collector.Start([]string{"svc"})
collector.Stop()
}
+7 -4
View File
@@ -199,8 +199,8 @@ func (w *DockerWatcher) handleEvent(event map[string]any) {
}
}
func (w *DockerWatcher) watchEvents(ctx context.Context) {
defer close(w.waitDone)
func (w *DockerWatcher) watchEvents(ctx context.Context, waitDone chan struct{}) {
defer close(waitDone)
filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`)
requestURL := "http://unix/events?filters=" + filters
for {
@@ -252,12 +252,14 @@ func (w *DockerWatcher) Start() {
w.mu.Unlock()
return
}
waitDone := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.waitDone = waitDone
w.running = true
w.mu.Unlock()
w.ScanExisting()
go w.watchEvents(ctx)
go w.watchEvents(ctx, waitDone)
}
func (w *DockerWatcher) Stop() {
@@ -268,9 +270,10 @@ func (w *DockerWatcher) Stop() {
}
w.running = false
cancel := w.cancel
waitDone := w.waitDone
w.mu.Unlock()
if cancel != nil {
cancel()
}
<-w.waitDone
<-waitDone
}
+8
View File
@@ -21,3 +21,11 @@ func TestDockerWatcherCommandAndThemeParsing(t *testing.T) {
t.Fatalf("unexpected slug: %q", slug)
}
}
func TestDockerWatcherCanRestart(t *testing.T) {
watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/does-not-exist.sock", nil, nil)
watcher.Start()
watcher.Stop()
watcher.Start()
watcher.Stop()
}
+55 -32
View File
@@ -48,11 +48,16 @@ type screenshotCacheEntry struct {
type wsClient struct {
routeKey string
conn *websocket.Conn
send chan []byte
send chan wsOutbound
done chan struct{}
closed atomic.Bool
}
type wsOutbound struct {
messageType int
payload []byte
}
type LocalServer struct {
host string
port int
@@ -89,12 +94,12 @@ type localClientConnector struct {
func (c *localClientConnector) OnData(data []byte) {
c.server.markRouteActivity(c.routeKey)
c.server.enqueueWSData(c.routeKey, data)
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data)
}
func (c *localClientConnector) OnBinary(payload []byte) {
c.server.markRouteActivity(c.routeKey)
c.server.enqueueWSData(c.routeKey, payload)
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload)
}
func (c *localClientConnector) OnMeta(_ map[string]any) {}
@@ -162,9 +167,11 @@ func findStaticPath() string {
}
}
candidates := []string{
filepath.Join(".", "src", "webterm", "static"),
filepath.Join("..", "src", "webterm", "static"),
filepath.Join("..", "..", "src", "webterm", "static"),
filepath.Join(".", "webterm", "static"),
filepath.Join(".", "go", "webterm", "static"),
filepath.Join("..", "webterm", "static"),
filepath.Join("..", "go", "webterm", "static"),
filepath.Join("..", "..", "webterm", "static"),
}
for _, candidate := range candidates {
if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
@@ -179,28 +186,37 @@ func (s *LocalServer) markRouteActivity(routeKey string) {
s.mu.Lock()
s.routeLastActivity[routeKey] = now
last := s.routeLastSSE[routeKey]
if now.Sub(last) >= 250*time.Millisecond {
s.routeLastSSE[routeKey] = now
for subscriber := range s.sseSubscribers {
select {
case subscriber <- routeKey:
default:
}
}
if now.Sub(last) < 250*time.Millisecond {
s.mu.Unlock()
return
}
s.routeLastSSE[routeKey] = now
subscribers := make([]chan string, 0, len(s.sseSubscribers))
for subscriber := range s.sseSubscribers {
subscribers = append(subscribers, subscriber)
}
s.mu.Unlock()
for _, subscriber := range subscribers {
select {
case subscriber <- routeKey:
default:
}
}
}
func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
func (s *LocalServer) enqueueWSFrame(routeKey string, messageType int, data []byte) {
s.mu.RLock()
client := s.wsClients[routeKey]
s.mu.RUnlock()
if client == nil || client.closed.Load() {
return
}
payload := append([]byte{}, data...)
frame := wsOutbound{
messageType: messageType,
payload: append([]byte{}, data...),
}
select {
case client.send <- payload:
case client.send <- frame:
default:
// Drop oldest, try again
select {
@@ -208,7 +224,7 @@ func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
default:
}
select {
case client.send <- payload:
case client.send <- frame:
default:
}
}
@@ -229,14 +245,9 @@ func (s *LocalServer) stopWSClient(routeKey string) {
func (s *LocalServer) wsSender(client *wsClient) {
defer close(client.done)
for payload := range client.send {
for outbound := range client.send {
_ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout))
// Detect JSON messages (start with '[') vs binary terminal data
msgType := websocket.BinaryMessage
if len(payload) > 0 && payload[0] == '[' {
msgType = websocket.TextMessage
}
if err := client.conn.WriteMessage(msgType, payload); err != nil {
if err := client.conn.WriteMessage(outbound.messageType, outbound.payload); err != nil {
return
}
}
@@ -304,7 +315,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
client := &wsClient{
routeKey: routeKey,
conn: conn,
send: make(chan []byte, wsSendQueueMax),
send: make(chan wsOutbound, wsSendQueueMax),
done: make(chan struct{}),
}
s.mu.Lock()
@@ -319,8 +330,12 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
if err != nil || client.closed.Load() {
return
}
frame := wsOutbound{
messageType: websocket.TextMessage,
payload: data,
}
select {
case client.send <- data:
case client.send <- frame:
default:
}
}
@@ -333,7 +348,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
sessionCreated = true
replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil)
if len(replay) > 0 {
s.enqueueWSData(routeKey, replay)
s.enqueueWSFrame(routeKey, websocket.BinaryMessage, replay)
}
} else {
s.sessionManager.OnSessionEnd(sessionID)
@@ -445,9 +460,18 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
if !ok && routeKey != "" {
if _, exists := s.sessionManager.AppBySlug(routeKey); exists {
_ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight)
time.Sleep(500 * time.Millisecond)
session = s.sessionManager.GetSessionByRouteKey(routeKey)
ok = session != nil
deadline := time.Now().Add(500 * time.Millisecond)
for {
session = s.sessionManager.GetSessionByRouteKey(routeKey)
if session != nil {
ok = true
break
}
if time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
}
}
if !ok || session == nil {
@@ -555,7 +579,6 @@ func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) {
defer func() {
s.mu.Lock()
delete(s.sseSubscribers, channel)
close(channel)
s.mu.Unlock()
}()
+28
View File
@@ -214,3 +214,31 @@ func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode)
}
}
func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{})
ready := make(chan string, 1)
full := make(chan string, 1)
full <- "occupied"
server.mu.Lock()
server.sseSubscribers[ready] = struct{}{}
server.sseSubscribers[full] = struct{}{}
server.routeLastSSE["route-a"] = time.Now().Add(-time.Second)
server.mu.Unlock()
start := time.Now()
server.markRouteActivity("route-a")
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("markRouteActivity took too long: %s", elapsed)
}
select {
case got := <-ready:
if got != "route-a" {
t.Fatalf("unexpected broadcast payload: %q", got)
}
default:
t.Fatalf("expected route activity broadcast")
}
}
+25
View File
@@ -32,3 +32,28 @@ func (noopConnector) OnData([]byte) {}
func (noopConnector) OnBinary([]byte) {}
func (noopConnector) OnMeta(map[string]any) {}
func (noopConnector) OnClose() {}
func dispatchSessionOutput(filtered []byte, tracker *terminalstate.Tracker, replay *ReplayBuffer, connector SessionConnector) {
if len(filtered) == 0 {
return
}
replay.Add(filtered)
if tracker != nil {
_ = tracker.Feed(filtered)
}
connector.OnData(filtered)
}
func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot {
if tracker != nil {
return tracker.Snapshot()
}
if height < 0 {
height = 0
}
return terminalstate.Snapshot{
Width: width,
Height: height,
Buffer: make([][]terminalstate.Cell, height),
}
}
+64
View File
@@ -0,0 +1,64 @@
package webterm
import (
"testing"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type captureConnector struct {
data [][]byte
}
func (c *captureConnector) OnData(data []byte) {
c.data = append(c.data, append([]byte{}, data...))
}
func (c *captureConnector) OnBinary([]byte) {}
func (c *captureConnector) OnMeta(map[string]any) {}
func (c *captureConnector) OnClose() {}
func TestDispatchSessionOutput(t *testing.T) {
replay := NewReplayBuffer(1024)
connector := &captureConnector{}
tracker := terminalstate.NewTracker(80, 24)
dispatchSessionOutput([]byte("hello\n"), tracker, replay, connector)
if got := string(replay.Bytes()); got != "hello\n" {
t.Fatalf("unexpected replay: %q", got)
}
if len(connector.data) != 1 || string(connector.data[0]) != "hello\n" {
t.Fatalf("unexpected connector data: %+v", connector.data)
}
}
func TestDispatchSessionOutputEmpty(t *testing.T) {
replay := NewReplayBuffer(1024)
connector := &captureConnector{}
dispatchSessionOutput(nil, nil, replay, connector)
dispatchSessionOutput([]byte{}, nil, replay, connector)
if got := string(replay.Bytes()); got != "" {
t.Fatalf("expected empty replay, got %q", got)
}
if len(connector.data) != 0 {
t.Fatalf("expected no connector events")
}
}
func TestSnapshotFromTrackerFallback(t *testing.T) {
snap := snapshotFromTracker(nil, 10, -2)
if snap.Width != 10 || snap.Height != 0 || len(snap.Buffer) != 0 {
t.Fatalf("unexpected fallback snapshot: %+v", snap)
}
}
func TestSnapshotFromTrackerWithTracker(t *testing.T) {
tracker := terminalstate.NewTracker(4, 2)
_ = tracker.Feed([]byte("ab"))
snap := snapshotFromTracker(tracker, 1, 1)
if snap.Width != 4 || snap.Height != 2 {
t.Fatalf("unexpected tracker snapshot dimensions: %+v", snap)
}
}

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+3 -17
View File
@@ -136,15 +136,7 @@ func (s *TerminalSession) handleOutput(data []byte) {
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(filtered) == 0 {
return
}
s.replay.Add(filtered)
if tracker != nil {
_ = tracker.Feed(filtered)
}
connector.OnData(filtered)
dispatchSessionOutput(filtered, tracker, s.replay, connector)
}
func (s *TerminalSession) Close() error {
@@ -233,15 +225,9 @@ func (s *TerminalSession) GetReplayBuffer() []byte {
func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot {
s.mu.RLock()
tracker := s.tracker
width, height := s.width, s.height
s.mu.RUnlock()
if tracker == nil {
return terminalstate.Snapshot{
Width: s.width,
Height: s.height,
Buffer: make([][]terminalstate.Cell, s.height),
}
}
return tracker.Snapshot()
return snapshotFromTracker(tracker, width, height)
}
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {
+4 -4
View File
@@ -9,10 +9,10 @@
"typescript": "^5.7.0"
},
"scripts": {
"build": "bun run typecheck && bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm src/webterm/static/js/",
"build:fast": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --watch --target=browser",
"build": "bun run typecheck && bun build go/webterm/static/js/terminal.ts --outfile=go/webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm go/webterm/static/js/",
"build:fast": "bun build go/webterm/static/js/terminal.ts --outfile=go/webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build go/webterm/static/js/terminal.ts --outfile=go/webterm/static/js/terminal.js --watch --target=browser",
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm src/webterm/static/js/"
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm go/webterm/static/js/"
}
}
Generated
-1168
View File
File diff suppressed because it is too large Load Diff
-117
View File
@@ -1,117 +0,0 @@
[tool.poetry]
name = "webterm"
version = "1.2.18"
description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
readme = "README.md"
packages = [{include = "webterm", from = "src"}]
include = [
{ path = "src/webterm/static/monospace.css" },
{ path = "src/webterm/static/js/terminal.js" },
{ path = "src/webterm/static/js/ghostty-vt.wasm" },
]
[tool.poetry.dependencies]
python = "^3.9"
aiohttp = "^3.13.0"
uvloop = { version = "^0.22.0", markers = "sys_platform != 'win32'" }
click = "^8.1.7"
pydantic = "^2.7.0"
importlib-metadata = ">=6.0.0"
tomli = { version = "^2.0.1", python = "<3.11" }
pyyaml = "^6.0.0"
pyte = "^0.8.0"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
pytest-asyncio = "^0.24.0"
pytest-cov = "^6.0.0"
pytest-timeout = "^2.3.0"
ruff = "^0.9.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
webterm = "webterm.cli:app"
[tool.ruff]
line-length = 100
target-version = "py39"
src = ["src"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"TC", # flake8-type-checking
"PTH", # flake8-use-pathlib
"ERA", # eradicate (commented out code)
"PL", # Pylint
"RUF", # Ruff-specific rules
]
ignore = [
"E501", # line too long (handled by formatter)
"PLR0912", # too many branches
"PLR0913", # too many arguments
"PLR0915", # too many statements
"PLR2004", # magic value comparison
"ARG001", # unused function argument
"ARG002", # unused method argument
"PLC0415", # import not at top level (needed for optional deps)
]
[tool.ruff.lint.isort]
known-first-party = ["webterm"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
timeout = 30
addopts = [
"-v",
"--tb=short",
"--strict-markers",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
[tool.coverage.run]
source = ["src/webterm"]
branch = true
omit = [
"*/tests/*",
"*/__pycache__/*",
# Thread/FD polling integration is harder to deterministically unit test
"*/poller.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"assert ",
]
# Unit test coverage target (75% due to Docker-dependent code that requires integration tests)
fail_under = 75
View File
-89
View File
@@ -1,89 +0,0 @@
from __future__ import annotations
import threading
from typing import Generic, TypeVar
Key = TypeVar("Key")
Value = TypeVar("Value")
class TwoWayDict(Generic[Key, Value]):
"""
A two-way mapping offering O(1) access in both directions.
Wraps two dictionaries and uses them to provide efficient access to
both values (given keys) and keys (given values).
"""
def __init__(self, initial: dict[Key, Value] | None = None) -> None:
initial_data = {} if initial is None else initial
self._forward: dict[Key, Value] = initial_data
self._reverse: dict[Value, Key] = {value: key for key, value in initial_data.items()}
self._lock = threading.RLock()
def __setitem__(self, key: Key, value: Value) -> None:
with self._lock:
# If reassigning the same key, remove old reverse mapping first
old_value = self._forward.get(key)
if old_value is not None and old_value != value:
del self._reverse[old_value]
# Enforce 1:1 mapping: value must not already map to a different key
existing_key = self._reverse.get(value)
if existing_key is not None and existing_key != key:
raise ValueError(f"Value {value!r} already mapped to key {existing_key!r}")
self._forward[key] = value
self._reverse[value] = key
def __delitem__(self, key: Key) -> None:
with self._lock:
value = self._forward[key]
self._forward.__delitem__(key)
self._reverse.__delitem__(value)
def __iter__(self):
with self._lock:
return iter(dict(self._forward))
def get(self, key: Key) -> Value | None:
"""Given a key, efficiently lookup and return the associated value.
Args:
key: The key
Returns:
The value
"""
with self._lock:
return self._forward.get(key)
def get_key(self, value: Value) -> Key | None:
"""Given a value, efficiently lookup and return the associated key.
Args:
value: The value
Returns:
The key
"""
with self._lock:
return self._reverse.get(value)
def contains_value(self, value: Value) -> bool:
"""Check if `value` is a value within this TwoWayDict.
Args:
value: The value to check.
Returns:
True if the value is within the values of this dict.
"""
with self._lock:
return value in self._reverse
def __len__(self):
with self._lock:
return len(self._forward)
def __contains__(self, item: Key) -> bool:
with self._lock:
return item in self._forward
-208
View File
@@ -1,208 +0,0 @@
"""Custom pyte Screen with alternate screen buffer support.
pyte's standard Screen class doesn't implement DECSET/DECRST 1049 (alternate screen buffer)
which causes issues when programs like tmux, vim, less, etc. switch between main and alternate
screens. Without this, screen clearing in tmux panes shows overlapping old and new content
in screenshots.
This module provides AltScreen, a Screen subclass that properly saves and restores
the screen buffer when switching between main and alternate screen modes.
"""
from __future__ import annotations
import copy
import re
from typing import TYPE_CHECKING, Any
import pyte
from pyte.screens import Margins
# Pattern to match a run of 3+ (EL2 + CUU1) pairs used by Ink/React CLI
# to erase the previous frame before drawing the next one.
_INK_CLEAR_PATTERN = re.compile(rb"(\x1b\[2K\x1b\[1A){3,}")
_EL2_CUU1 = b"\x1b[2K\x1b[1A"
# Patch pyte's CSI dispatch table to handle SU (Scroll Up, CSI S) and
# SD (Scroll Down, CSI T). Without this, tmux output using xterm-256color
# sends CSI S for scrolling which pyte silently ignores, causing ghost
# content to remain on screen.
pyte.ByteStream.csi["S"] = "scroll_up"
pyte.ByteStream.csi["T"] = "scroll_down"
pyte.Stream.csi["S"] = "scroll_up"
pyte.Stream.csi["T"] = "scroll_down"
# Update the events frozenset so pyte recognises these as valid events.
pyte.Stream.events = pyte.Stream.events | frozenset(["scroll_up", "scroll_down"])
if TYPE_CHECKING:
from pyte.screens import Char
# Private mode alternate screen buffers (1047/1048/1049/47) - shifted by 5 per pyte's convention
DECALTBUF = 1049 << 5
DECALTBUF_1047 = 1047 << 5
DECALTBUF_1048 = 1048 << 5
DECALTBUF_47 = 47 << 5
class AltScreen(pyte.Screen):
"""A pyte Screen with proper alternate screen buffer support.
Implements DECSET/DECRST 1049 to save and restore the main screen buffer
when programs switch to alternate screen mode.
"""
def __init__(self, columns: int, lines: int, *args: Any, **kwargs: Any) -> None:
super().__init__(columns, lines, *args, **kwargs)
# Storage for main screen state when in alternate mode
self._saved_buffer: dict[int, dict[int, Char]] | None = None
self._saved_cursor: pyte.screens.Cursor | None = None
def _save_main_screen(self) -> None:
"""Save the current screen buffer and cursor for later restoration."""
# Deep copy the buffer to avoid aliasing
# Save all rows within current screen bounds with all their column data
self._saved_buffer = {}
for row_idx in range(self.lines):
self._saved_buffer[row_idx] = {
col: self.buffer[row_idx][col] for col in range(self.columns)
}
# Save cursor state
self._saved_cursor = copy.copy(self.cursor)
def _restore_main_screen(self) -> None:
"""Restore the previously saved screen buffer and cursor."""
if self._saved_buffer is not None:
# Restore buffer - copy characters into existing line structures
for row_idx in range(self.lines):
if row_idx in self._saved_buffer:
saved_row = self._saved_buffer[row_idx]
for col in range(self.columns):
if col in saved_row:
self.buffer[row_idx][col] = saved_row[col]
else:
self.buffer[row_idx][col] = self.default_char
else:
# Clear rows that weren't in saved buffer
for col in range(self.columns):
self.buffer[row_idx][col] = self.default_char
self._saved_buffer = None
if self._saved_cursor is not None:
self.cursor = self._saved_cursor
self._saved_cursor = None
# Mark all lines as dirty for re-render
self.dirty.update(range(self.lines))
def _is_alt_buffer_mode(self, modes: tuple[int, ...]) -> bool:
return 47 in modes or 1047 in modes or 1048 in modes or 1049 in modes
def _has_alt_buffer_enabled(self) -> bool:
return (
DECALTBUF in self.mode
or DECALTBUF_1047 in self.mode
or DECALTBUF_1048 in self.mode
or DECALTBUF_47 in self.mode
)
def set_mode(self, *modes: int, **kwargs: Any) -> None:
"""Set (enable) modes, with special handling for alternate screen buffer."""
# Check if we're entering alternate screen mode (private mode 47/1047/1048/1049)
if kwargs.get("private") and self._is_alt_buffer_mode(modes) and not self._has_alt_buffer_enabled():
# Save main screen before switching
self._save_main_screen()
# Clear screen for alternate buffer
self.erase_in_display(2)
self.cursor_position()
# Call parent implementation
super().set_mode(*modes, **kwargs)
def reset_mode(self, *modes: int, **kwargs: Any) -> None:
"""Reset (disable) modes, with special handling for alternate screen buffer."""
# Check if we're leaving alternate screen mode (private mode 47/1047/1048/1049)
if kwargs.get("private") and self._is_alt_buffer_mode(modes) and self._has_alt_buffer_enabled():
# Will be removed by parent, restore main screen after
super().reset_mode(*modes, **kwargs)
self._restore_main_screen()
return
# Call parent implementation
super().reset_mode(*modes, **kwargs)
def resize(self, lines: int | None = None, columns: int | None = None) -> None:
"""Resize screen, clearing saved alternate buffer if size changes."""
# If we're in alternate mode and resizing, the saved buffer may be invalid
if self._saved_buffer is not None and (
(lines is not None and lines != self.lines)
or (columns is not None and columns != self.columns)
):
# Invalidate saved buffer on resize - it won't match the new dimensions
self._saved_buffer = None
self._saved_cursor = None
super().resize(lines, columns)
def scroll_up(self, count: int = 1) -> None:
"""Scroll the screen up by *count* lines within the scroll region.
Lines scrolled off the top are lost; blank lines are added at the
bottom. The cursor position is not changed.
Implements CSI n S (SU — Scroll Up), which pyte does not handle
natively. tmux sends this when TERM supports the ``indn``
capability (e.g. xterm-256color).
"""
top, bottom = self.margins or Margins(0, self.lines - 1)
self.dirty.update(range(self.lines))
for _ in range(min(count, bottom - top + 1)):
for y in range(top, bottom):
self.buffer[y] = self.buffer[y + 1]
self.buffer.pop(bottom, None)
def scroll_down(self, count: int = 1) -> None:
"""Scroll the screen down by *count* lines within the scroll region.
Lines scrolled off the bottom are lost; blank lines are added at
the top. The cursor position is not changed.
Implements CSI n T (SD — Scroll Down).
"""
top, bottom = self.margins or Margins(0, self.lines - 1)
self.dirty.update(range(self.lines))
for _ in range(min(count, bottom - top + 1)):
for y in range(bottom, top, -1):
self.buffer[y] = self.buffer[y - 1]
self.buffer.pop(top, None)
def expand_clear_sequences(self, data: bytes) -> bytes:
"""Expand partial line-by-line clears to cover the full screen.
CLI frameworks like Ink (React for terminals) erase their previous
output using repeated ``EL2 + CUU1`` (erase line, cursor up) sequences.
When the application's ``/clear`` command resets the framework's internal
line counter, the next frame only erases a few lines instead of the full
previous output. In a real terminal the old content has scrolled into the
scrollback buffer, but pyte keeps it visible, producing ghost content in
screenshots.
This method detects such partial clears and extends them so that all
lines from the cursor position up to row 0 are erased.
"""
if _EL2_CUU1 not in data:
return data
cursor_y = self.cursor.y
def _extend(match: re.Match[bytes]) -> bytes:
nonlocal cursor_y
run = match.group(0)
pair_count = len(run) // len(_EL2_CUU1)
extra = cursor_y - pair_count
cursor_y = max(cursor_y - pair_count, 0)
if extra > 0:
return run + _EL2_CUU1 * extra
return run
return _INK_CLEAR_PATTERN.sub(_extend, data)
-169
View File
@@ -1,169 +0,0 @@
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()
-153
View File
@@ -1,153 +0,0 @@
from os.path import expandvars
from pathlib import Path
from typing import Annotated
try:
import tomllib as tomli # py311+
except ImportError: # pragma: no cover
import tomli
import yaml
from pydantic import BaseModel, Field
from pydantic.functional_validators import AfterValidator
from .identity import generate
from .slugify import slugify
ExpandVarsStr = Annotated[str, AfterValidator(expandvars)]
class App(BaseModel):
"""Defines an application."""
name: str
slug: str = ""
path: ExpandVarsStr = "./"
color: str = ""
command: ExpandVarsStr = ""
terminal: bool = False
theme: str | None = None
class Config(BaseModel):
"""Root configuration model."""
apps: list[App] = Field(default_factory=list)
def default_config() -> Config:
"""Get a default empty configuration.
Returns:
Configuration object.
"""
return Config()
def load_config(config_path: Path) -> Config:
"""Load config from a path.
Args:
config_path: Path to TOML configuration.
Returns:
Config object.
"""
with Path(config_path).open("rb") as config_file:
config_data = tomli.load(config_file)
def make_app(name, data: dict[str, object], terminal: bool = False) -> App:
data["name"] = name
data["terminal"] = terminal
if terminal:
data["slug"] = generate().lower()
elif not data.get("slug", ""):
data["slug"] = slugify(name)
return App(**data)
terminal_entries = config_data.get("terminal", {})
app_entries = config_data.get("app", {})
if app_entries:
raise ValueError("App manifests are no longer supported; use [terminal.*] entries only.")
apps = [make_app(name, app, terminal=True) for name, app in terminal_entries.items()]
config = Config(apps=apps)
return config
def load_landing_yaml(manifest_path: Path) -> list[App]:
"""Load landing apps from YAML manifest.
Expected schema: list of {name, slug, command, color?, path?, terminal?}
"""
with manifest_path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or []
apps: list[App] = []
for entry in data:
if not isinstance(entry, dict):
continue
name = entry.get("name")
command = entry.get("command")
if not name or not command:
continue
slug = entry.get("slug") or slugify(name)
apps.append(
App(
name=name,
slug=slug,
command=command,
path=entry.get("path", "./"),
color=entry.get("color", ""),
terminal=bool(entry.get("terminal", True)),
)
)
return apps
def _extract_label(labels: object, key: str) -> str | None:
"""Extract a label value from either dict or list[str] forms."""
if isinstance(labels, dict):
value = labels.get(key)
if isinstance(value, str):
return value
return None
if isinstance(labels, list):
for item in labels:
if not isinstance(item, str):
continue
if "=" in item:
k, v = item.split("=", 1)
if k == key:
return v
return None
def load_compose_manifest(manifest_path: Path) -> list[App]:
"""Load landing apps from a docker-compose YAML file using label `webterm-command`."""
with manifest_path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
services = data.get("services", {}) if isinstance(data, dict) else {}
apps: list[App] = []
for name, service in services.items():
if not isinstance(service, dict):
continue
labels = service.get("labels", {})
command = _extract_label(labels, "webterm-command")
if not command:
continue
theme = _extract_label(labels, "webterm-theme")
slug = slugify(name)
apps.append(
App(
name=name,
slug=slug,
command=command,
path=service.get("working_dir", "./"),
color="",
terminal=True,
theme=theme,
)
)
return apps
-56
View File
@@ -1,56 +0,0 @@
"""
Constants that we might want to expose via the public API.
"""
from __future__ import annotations
import os
import platform
from typing import Final
get_environ = os.environ.get
WINDOWS: Final = platform.system() == "Windows"
"""True if running on Windows."""
def get_environ_bool(name: str) -> bool:
"""Check an environment variable switch.
Args:
name: Name of environment variable.
Returns:
`True` if the env var is a truthy value, otherwise `False`.
"""
value = get_environ(name)
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def get_environ_int(name: str, default: int) -> int:
"""Retrieves an integer environment variable.
Args:
name: Name of environment variable.
default: The value to use if the value is not set, or set to something other
than a valid integer.
Returns:
The integer associated with the environment variable if it's set to a valid int
or the default value otherwise.
"""
try:
return int(os.environ[name])
except KeyError:
return default
except ValueError:
return default
DEBUG: Final = get_environ_bool("DEBUG")
"""Enable debug mode."""
SCREENSHOT_FORCE_REDRAW_ENV: Final = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
"""Environment variable to force redraw before screenshots."""
-464
View File
@@ -1,464 +0,0 @@
"""Docker exec-based terminal session using Docker API and socket."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import re
import socket
from collections import deque
from dataclasses import dataclass
from typing import TYPE_CHECKING
import pyte
from .alt_screen import AltScreen
from .docker_stats import get_docker_socket_path
from .session import Session, SessionConnector
if TYPE_CHECKING:
from .poller import Poller
from .types import Meta, SessionID
log = logging.getLogger("webterm")
REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB
DEFAULT_SCREEN_WIDTH = 132
DEFAULT_SCREEN_HEIGHT = 45
# Pattern to filter out terminal device attribute responses that cause display issues
# These are responses to DA1/DA2/DA3 queries that shouldn't be displayed as text
# Matches complete responses like:
# \x1b[?1;10;0c (DA1 - Primary Device Attributes)
# \x1b[>1;10;0c (DA2 - Secondary Device Attributes, sent by tmux)
# \x1b[=1;0c (DA3 - Tertiary Device Attributes)
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c")
# Pattern to detect partial DA responses at end of data (incomplete escape sequence)
# Matches: \x1b, \x1b[, \x1b[?, \x1b[>, \x1b[=, \x1b[?1, \x1b[>1;10, etc.
# These need to be held back until more data arrives to see if they complete
DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:[?>=][\d;]*)?)?$")
# Map C1 control sequences to 7-bit ESC equivalents for pyte compatibility
CSI_C1 = b"\x9b"
OSC_C1 = b"\x9d"
ST_C1 = b"\x9c"
DCS_C1 = b"\x90"
SOS_C1 = b"\x98"
PM_C1 = b"\x9e"
APC_C1 = b"\x9f"
def _normalize_c1_controls(data: bytes, utf8_buffer: bytes = b"") -> tuple[bytes, bytes]:
if not data and not utf8_buffer:
return b"", b""
data = utf8_buffer + data
out = bytearray()
pending_utf8 = bytearray()
expected_continuations = 0
c1_map = {
0x9B: b"\x1b[",
0x9D: b"\x1b]",
0x9C: b"\x1b\\",
0x90: b"\x1bP",
0x98: b"\x1bX",
0x9E: b"\x1b^",
0x9F: b"\x1b_",
}
idx = 0
while idx < len(data):
byte = data[idx]
if expected_continuations:
if 0x80 <= byte <= 0xBF:
pending_utf8.append(byte)
expected_continuations -= 1
idx += 1
if expected_continuations == 0:
out.extend(pending_utf8)
pending_utf8.clear()
continue
out.extend(pending_utf8)
pending_utf8.clear()
expected_continuations = 0
continue
if 0xC2 <= byte <= 0xDF:
pending_utf8.append(byte)
expected_continuations = 1
idx += 1
continue
if 0xE0 <= byte <= 0xEF:
pending_utf8.append(byte)
expected_continuations = 2
idx += 1
continue
if 0xF0 <= byte <= 0xF4:
pending_utf8.append(byte)
expected_continuations = 3
idx += 1
continue
replacement = c1_map.get(byte)
if replacement is not None:
out.extend(replacement)
else:
out.append(byte)
idx += 1
if pending_utf8:
return bytes(out), bytes(pending_utf8)
return bytes(out), b""
@dataclass(frozen=True)
class DockerExecSpec:
container: str
command: list[str]
user: str | None = None
class DockerExecSession(Session):
"""Terminal session backed by Docker exec API."""
def __init__(
self,
poller: Poller,
session_id: SessionID,
exec_spec: DockerExecSpec,
socket_path: str | None = None,
) -> None:
self.poller = poller
self.session_id = session_id
self.exec_spec = exec_spec
self._socket_path = socket_path or get_docker_socket_path()
self.master_fd: int | None = None
self._sock: socket.socket | None = None
self._task: asyncio.Task | None = None
self._connector = SessionConnector()
self._replay_buffer: deque[bytes] = deque()
self._replay_buffer_size = 0
self._replay_lock = asyncio.Lock()
self._screen = AltScreen(DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT)
self._stream = pyte.ByteStream(self._screen)
self._screen_lock = asyncio.Lock()
self._last_width = DEFAULT_SCREEN_WIDTH
self._last_height = DEFAULT_SCREEN_HEIGHT
self._change_counter = 0
self._last_snapshot_counter = 0
self._exec_id: str | None = None
self._pending_output = b""
# Buffer for handling escape sequences split across socket reads
self._escape_buffer = b""
self._utf8_buffer = b""
def __repr__(self) -> str:
return (
"DockerExecSession(session_id="
f"{self.session_id!r}, container={self.exec_spec.container!r})"
)
def _read_http_response(self, sock: socket.socket) -> tuple[int, dict, bytes]:
sock.settimeout(10.0)
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if b"\r\n\r\n" not in data:
return 0, {}, b""
header_bytes, body = data.split(b"\r\n\r\n", 1)
headers = header_bytes.decode("utf-8", errors="replace").split("\r\n")
status_line = headers[0] if headers else ""
status = 0
if status_line:
parts = status_line.split()
if len(parts) >= 2:
try:
status = int(parts[1])
except ValueError:
status = 0
header_map: dict[str, str] = {}
for header in headers[1:]:
if ":" not in header:
continue
key, value = header.split(":", 1)
header_map[key.strip().lower()] = value.strip()
if "content-length" in header_map:
try:
length = int(header_map["content-length"])
except ValueError:
length = 0
remaining = length - len(body)
while remaining > 0:
chunk = sock.recv(min(4096, remaining))
if not chunk:
break
body += chunk
remaining -= len(chunk)
return status, header_map, body
def _request_json(self, method: str, path: str, payload: dict | None = None) -> dict:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(self._socket_path)
body = json.dumps(payload or {}).encode("utf-8") if payload is not None else b""
headers = [
f"{method} {path} HTTP/1.1",
"Host: localhost",
]
if payload is not None:
headers.append("Content-Type: application/json")
headers.append(f"Content-Length: {len(body)}")
headers.append("")
headers.append("")
request = "\r\n".join(headers).encode("utf-8") + body
sock.sendall(request)
status, _headers, body_bytes = self._read_http_response(sock)
finally:
sock.close()
if status < 200 or status >= 300:
detail = body_bytes.decode("utf-8", errors="replace")
raise RuntimeError(f"Docker API request failed ({status}): {detail}")
if not body_bytes:
return {}
try:
return json.loads(body_bytes.decode("utf-8", errors="replace"))
except json.JSONDecodeError as exc:
raise RuntimeError("Docker API returned invalid JSON") from exc
def _create_exec(self) -> str:
payload = {
"AttachStdin": True,
"AttachStdout": True,
"AttachStderr": True,
"Tty": True,
"Cmd": self.exec_spec.command,
}
if self.exec_spec.user:
payload["User"] = self.exec_spec.user
response = self._request_json(
"POST", f"/containers/{self.exec_spec.container}/exec", payload
)
exec_id = response.get("Id")
if not isinstance(exec_id, str) or not exec_id:
raise RuntimeError("Docker API did not return exec ID")
return exec_id
def _start_exec_socket(self, exec_id: str) -> socket.socket:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(self._socket_path)
payload = json.dumps({"Detach": False, "Tty": True}).encode("utf-8")
headers = [
f"POST /exec/{exec_id}/start HTTP/1.1",
"Host: localhost",
"Content-Type: application/json",
f"Content-Length: {len(payload)}",
"Connection: Upgrade",
"Upgrade: tcp",
"",
"",
]
sock.sendall("\r\n".join(headers).encode("utf-8") + payload)
status, _headers, body = self._read_http_response(sock)
if status not in (101,) and (status < 200 or status >= 300):
detail = body.decode("utf-8", errors="replace")
raise RuntimeError(f"Docker API exec start failed ({status}): {detail}")
# Don't save body from HTTP upgrade - it contains protocol handshake data,
# not real terminal output (e.g., device attribute responses like "\x1b[?1;10;0c")
sock.settimeout(None)
return sock
except Exception:
sock.close()
raise
def _resize_exec(self, width: int, height: int) -> None:
assert self._exec_id is not None
path = f"/exec/{self._exec_id}/resize?h={height}&w={width}"
self._request_json("POST", path, None)
async def open(self, width: int = 80, height: int = 24) -> None:
log.info(
"Opening Docker exec session %s for %s",
self.session_id,
self.exec_spec.container,
)
self._last_width = width
self._last_height = height
async with self._screen_lock:
self._screen = AltScreen(width, height)
self._stream = pyte.ByteStream(self._screen)
exec_id = await asyncio.to_thread(self._create_exec)
self._exec_id = exec_id
self._sock = await asyncio.to_thread(self._start_exec_socket, exec_id)
self.master_fd = self._sock.fileno()
await asyncio.to_thread(self._resize_exec, width, height)
async def set_terminal_size(self, width: int, height: int) -> None:
self._last_width = width
self._last_height = height
async with self._screen_lock:
self._screen.resize(height, width)
self._change_counter += 1
if self._exec_id:
await asyncio.to_thread(self._resize_exec, width, height)
async def force_redraw(self) -> None:
await self.set_terminal_size(self._last_width, self._last_height)
async def _add_to_replay_buffer(self, data: bytes) -> None:
async with self._replay_lock:
self._replay_buffer.append(data)
self._replay_buffer_size += len(data)
while self._replay_buffer_size > REPLAY_BUFFER_SIZE and self._replay_buffer:
old = self._replay_buffer.popleft()
self._replay_buffer_size -= len(old)
async def _update_screen(self, data: bytes) -> None:
async with self._screen_lock:
try:
normalized, self._utf8_buffer = _normalize_c1_controls(data, self._utf8_buffer)
if not normalized:
return
normalized = self._screen.expand_clear_sequences(normalized)
self._stream.feed(normalized)
if self._screen.dirty:
self._change_counter += 1
except Exception as exc:
log.warning(
"Docker exec screen update failed (%s): %s",
type(exc).__name__,
exc,
)
async def _drain_pending_output(self) -> None:
if not self._pending_output:
return
data = self._pending_output
self._pending_output = b""
await self._add_to_replay_buffer(data)
await self._update_screen(data)
if self._connector:
await self._connector.on_data(data)
async def get_replay_buffer(self) -> bytes:
async with self._replay_lock:
return b"".join(self._replay_buffer)
async def get_screen_lines(self) -> list[str]:
async with self._screen_lock:
return [line.rstrip() for line in self._screen.display]
async def get_screen_snapshot(self) -> tuple[int, int, list, bool]:
async with self._screen_lock:
width = self._screen.columns
height = self._screen.lines
has_changes = self._change_counter > self._last_snapshot_counter
self._last_snapshot_counter = self._change_counter
snapshot = [
[self._screen.buffer[row][col] for col in range(width)] for row in range(height)
]
buffer = []
for row_data in snapshot:
row_chars = []
for char in row_data:
row_chars.append(
{
"data": char.data if char.data else " ",
"fg": char.fg,
"bg": char.bg,
"bold": char.bold,
"italics": char.italics,
"underscore": char.underscore,
"reverse": char.reverse,
}
)
buffer.append(row_chars)
return (width, height, buffer, has_changes)
def update_connector(self, connector: SessionConnector) -> None:
self._connector = connector
async def start(self, connector: SessionConnector) -> asyncio.Task:
self._connector = connector
if self.master_fd is None:
raise RuntimeError("Docker exec session not opened")
if self._task is not None:
return self._task
self._task = asyncio.create_task(self.run())
return self._task
async def run(self) -> None:
assert self.master_fd is not None
queue = self.poller.add_file(self.master_fd)
try:
await self._drain_pending_output()
while True:
data = await queue.get()
if not data:
break
# Prepend any buffered partial escape sequence from previous read
if self._escape_buffer:
data = self._escape_buffer + data
self._escape_buffer = b""
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
data = DA_RESPONSE_PATTERN.sub(b"", data)
if not data:
continue
# Check for partial escape sequence at end that might be a DA response
# Hold it back until we get more data to see if it completes
match = DA_PARTIAL_PATTERN.search(data)
if match:
self._escape_buffer = data[match.start() :]
data = data[: match.start()]
if not data:
continue
await self._add_to_replay_buffer(data)
await self._update_screen(data)
if self._connector:
await self._connector.on_data(data)
except OSError:
log.exception("error in docker exec session run")
finally:
if self._connector:
await self._connector.on_close()
if self.master_fd is not None:
fd = self.master_fd
self.master_fd = None
self.poller.remove_file(fd)
if self._sock is not None:
self._sock.close()
self._sock = None
async def send_bytes(self, data: bytes) -> bool:
fd = self.master_fd
if fd is None:
return False
try:
await self.poller.write(fd, data)
except (KeyError, OSError):
return False
return True
async def send_meta(self, data: Meta) -> bool:
return True
async def close(self) -> None:
if self._task is not None and not self._task.done():
self._task.cancel()
if self._sock is not None:
self._sock.close()
self._sock = None
async def wait(self, timeout: float = 2.0) -> None:
if self._task is not None:
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
await asyncio.wait_for(asyncio.shield(self._task), timeout=timeout)
def is_running(self) -> bool:
return not (self.master_fd is None or self._task is None)
-385
View File
@@ -1,385 +0,0 @@
"""Docker container CPU stats via Unix socket.
Reads container stats from Docker socket using only asyncio and stdlib.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import socket
from collections import deque
from pathlib import Path
log = logging.getLogger("webterm")
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
def get_docker_socket_path() -> str:
"""Get Docker socket path from DOCKER_HOST env var or default.
Supports unix:// scheme or plain path in DOCKER_HOST.
"""
docker_host = os.environ.get("DOCKER_HOST", "")
if docker_host:
if docker_host.startswith("unix://"):
return docker_host[7:] # Strip unix:// prefix
if docker_host.startswith("/"):
return docker_host
return DEFAULT_DOCKER_SOCKET
STATS_HISTORY_SIZE = 180 # Number of CPU readings to keep (30 min at 10s interval)
POLL_INTERVAL = 10.0 # Seconds between polls
class DockerStatsCollector:
"""Collects CPU stats from Docker containers via the Docker socket."""
def __init__(self, socket_path: str | None = None, compose_project: str | None = None) -> None:
self._socket_path = socket_path or get_docker_socket_path()
self._compose_project = compose_project
# container_name -> deque of CPU % values (0-100)
self._cpu_history: dict[str, deque[float]] = {}
self._running = False
self._task: asyncio.Task | None = None
# Track previous CPU values for delta calculation
self._prev_cpu: dict[str, tuple[int, int]] = {}
# Service names to poll (can be modified dynamically)
self._service_names: list[str] = []
self._service_names_lock = asyncio.Lock()
@property
def available(self) -> bool:
"""Check if Docker socket is available and accessible."""
path = Path(self._socket_path)
if not path.exists():
return False
# Also check we can actually connect
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.connect(self._socket_path)
sock.close()
return True
except (OSError, TimeoutError) as e:
log.warning("Docker socket exists but not accessible: %s", e)
return False
def get_cpu_history(self, container_name: str) -> list[float]:
"""Get CPU history for a container."""
if container_name not in self._cpu_history:
return []
return list(self._cpu_history[container_name])
async def _make_request(self, path: str) -> dict | list | None:
"""Make HTTP request to Docker socket."""
loop = asyncio.get_event_loop()
def _sync_request() -> bytes | None:
sock: socket.socket | None = None
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(10.0) # Increased timeout
sock.connect(self._socket_path)
# Use HTTP/1.0 to avoid chunked encoding
request = f"GET {path} HTTP/1.0\r\nHost: localhost\r\n\r\n"
sock.sendall(request.encode())
# Read response
chunks = []
while True:
chunk = sock.recv(8192)
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
except (OSError, TimeoutError) as e:
log.debug("Socket error for %s: %s", path, e)
return None
finally:
if sock is not None:
sock.close()
response = await loop.run_in_executor(None, _sync_request)
if response is None:
log.debug("No response from Docker socket for %s", path)
return None
return self._parse_docker_response(path, response)
def _parse_docker_response(self, path: str, response: bytes) -> dict | list | None:
"""Parse HTTP response from Docker socket."""
try:
response_str = response.decode("utf-8", errors="replace")
# Split headers and body
if "\r\n\r\n" not in response_str:
return None
headers, body = response_str.split("\r\n\r\n", 1)
# Check for error status
first_line = headers.split("\r\n")[0]
if "200" not in first_line and "OK" not in first_line:
return None
body = body.strip()
# With HTTP/1.0, body should be plain JSON
if body.startswith("{") or body.startswith("["):
return json.loads(body)
# Fallback: try to find JSON in body
for line in body.split("\r\n"):
stripped = line.strip()
if stripped.startswith("{") or stripped.startswith("["):
return json.loads(stripped)
return None
except (json.JSONDecodeError, Exception):
return None
async def _discover_containers(self, service_names: list[str]) -> dict[str, str]:
"""Map service names to container IDs by querying Docker.
Returns:
Dict mapping service_name -> container_id
"""
# List all containers
containers = await self._make_request("/containers/json")
if not isinstance(containers, list):
return {}
mapping: dict[str, str] = {}
for container in containers:
if not isinstance(container, dict):
continue
container_id = container.get("Id", "")[:12] # Short ID
names = container.get("Names", [])
labels = container.get("Labels", {})
# Filter by compose project if specified
if self._compose_project:
project = labels.get("com.docker.compose.project", "")
if project != self._compose_project:
continue
# Check compose service label
service = labels.get("com.docker.compose.service", "")
if service in service_names:
mapping[service] = container_id
continue
# Fall back to container name matching
for name in names:
# Docker names start with /
clean_name = name.lstrip("/")
# Check if service name is part of container name
for svc in service_names:
if svc in clean_name or clean_name == svc:
mapping[svc] = container_id
break
if mapping:
log.debug(
"Discovered %d containers for stats (project=%s)",
len(mapping),
self._compose_project,
)
return mapping
def _calculate_cpu_percent(
self, container: str, cpu_stats: dict, precpu_stats: dict
) -> float | None:
"""Calculate CPU percentage from stats.
Formula: (cpu_delta / system_delta) * num_cpus * 100
"""
try:
cpu_usage = cpu_stats.get("cpu_usage", {})
precpu_usage = precpu_stats.get("cpu_usage", {})
cpu_total = cpu_usage.get("total_usage", 0)
precpu_total = precpu_usage.get("total_usage", 0)
system_cpu = cpu_stats.get("system_cpu_usage", 0)
presystem_cpu = precpu_stats.get("system_cpu_usage", 0)
# Use previous values if precpu_stats is empty (first read)
if precpu_total == 0 and container in self._prev_cpu:
precpu_total, presystem_cpu = self._prev_cpu[container]
# Store current values for next calculation
self._prev_cpu[container] = (cpu_total, system_cpu)
cpu_delta = cpu_total - precpu_total
system_delta = system_cpu - presystem_cpu
if system_delta <= 0 or cpu_delta < 0:
return None
# Get number of CPUs
online_cpus = cpu_stats.get("online_cpus")
if online_cpus is None:
percpu = cpu_usage.get("percpu_usage", [])
online_cpus = len(percpu) if percpu else 1
cpu_percent = (cpu_delta / system_delta) * online_cpus * 100.0
return min(cpu_percent, 100.0 * online_cpus) # Cap at max possible
except (KeyError, TypeError, ZeroDivisionError):
return None
async def _poll_container(self, service_name: str, container_id: str) -> None:
"""Poll stats for a single container."""
path = f"/containers/{container_id}/stats?stream=false"
stats = await self._make_request(path)
if not isinstance(stats, dict):
return
cpu_stats = stats.get("cpu_stats", {})
precpu_stats = stats.get("precpu_stats", {})
cpu_percent = self._calculate_cpu_percent(service_name, cpu_stats, precpu_stats)
if cpu_percent is not None:
if service_name not in self._cpu_history:
self._cpu_history[service_name] = deque(maxlen=STATS_HISTORY_SIZE)
self._cpu_history[service_name].append(cpu_percent)
async def _poll_loop(self) -> None:
"""Background polling loop."""
# Discover container IDs on first run and periodically refresh
service_to_container: dict[str, str] = {}
refresh_counter = 0
warned_no_containers = False
while self._running:
# Get current service names (may change dynamically)
service_names = list(self._service_names)
# Refresh container mapping every 30 iterations (~5 minutes at 10s interval)
# or immediately if service list changed
if refresh_counter % 30 == 0 or set(service_to_container.keys()) != set(service_names):
service_to_container = await self._discover_containers(service_names)
if not service_to_container and service_names and not warned_no_containers:
log.warning(
"No Docker containers found for CPU stats. "
"Ensure Docker socket is mounted (-v /var/run/docker.sock:/var/run/docker.sock)"
)
warned_no_containers = True
refresh_counter += 1
for service_name in service_names:
if not self._running:
break
container_id = service_to_container.get(service_name)
if not container_id:
continue
try:
await self._poll_container(service_name, container_id)
except Exception as e:
log.debug("Error polling stats for %s: %s", service_name, e)
await asyncio.sleep(POLL_INTERVAL)
def start(self, service_names: list[str]) -> None:
"""Start collecting stats for given service names."""
if not self.available:
log.debug("Docker socket not available at %s", self._socket_path)
return
if self._running:
return
self._service_names = list(service_names)
self._running = True
self._task = asyncio.create_task(self._poll_loop())
log.info("Started Docker stats collection for %d services", len(service_names))
def add_service(self, service_name: str) -> None:
"""Add a service to the polling list dynamically.
This is safe to call while the collector is running - the poll loop
will pick up the new service on its next container discovery cycle.
"""
if service_name not in self._service_names:
self._service_names.append(service_name)
log.debug("Added service to stats collector: %s", service_name)
def remove_service(self, service_name: str) -> None:
"""Remove a service from the polling list."""
if service_name in self._service_names:
self._service_names.remove(service_name)
# Clean up history for removed service
self._cpu_history.pop(service_name, None)
self._prev_cpu.pop(service_name, None)
log.debug("Removed service from stats collector: %s", service_name)
async def stop(self) -> None:
"""Stop collecting stats."""
self._running = False
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
def render_sparkline_svg(
values: list[float],
width: int = 100,
height: int = 20,
stroke_color: str = "#4ade80",
fill_color: str = "rgba(74, 222, 128, 0.2)",
) -> str:
"""Render a list of values as an SVG sparkline.
Args:
values: List of values to plot (0-100 range expected for CPU %)
width: SVG width in pixels
height: SVG height in pixels
stroke_color: Line color
fill_color: Fill color under the line
Returns:
SVG string
"""
if not values:
# Empty placeholder
return f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg"></svg>'
# Prepend zero to start sparkline from baseline
values = [0.0, *values]
# Normalize values to 0-1 range
max_val = max(values) if max(values) > 0 else 1
normalized = [v / max_val for v in values]
# Calculate points
points = []
x_step = width / max(len(values) - 1, 1)
for i, v in enumerate(normalized):
x = i * x_step
y = height - (v * (height - 2)) - 1 # Leave 1px margin
points.append(f"{x:.1f},{y:.1f}")
path_line = " ".join(points)
# Create filled area path (line + close to bottom)
fill_points = [*points, f"{width},{height}", f"0,{height}"]
path_fill = " ".join(fill_points)
svg = f'''<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
<polygon points="{path_fill}" fill="{fill_color}" />
<polyline points="{path_line}" fill="none" stroke="{stroke_color}" stroke-width="1.5" />
</svg>'''
return svg
-329
View File
@@ -1,329 +0,0 @@
"""Docker container event watcher for dynamic session management.
Watches Docker events and creates/removes terminal sessions for containers
with the 'webterm-command' label.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
from typing import TYPE_CHECKING, Callable
from .docker_stats import get_docker_socket_path
if TYPE_CHECKING:
from .session_manager import SessionManager
log = logging.getLogger("webterm")
LABEL_NAME = "webterm-command"
THEME_LABEL = "webterm-theme"
# All labels that trigger container inclusion
WEBTERM_LABELS = (LABEL_NAME, THEME_LABEL)
AUTO_COMMAND_ENV = "WEBTERM_DOCKER_AUTO_COMMAND"
DEFAULT_COMMAND = "/bin/bash"
AUTO_COMMAND_SENTINEL = "__docker_exec__"
def _has_webterm_label(attributes: dict) -> bool:
"""Check if a container has any webterm label."""
return any(label in attributes for label in WEBTERM_LABELS)
def _get_auto_command() -> str:
return os.environ.get(AUTO_COMMAND_ENV, DEFAULT_COMMAND)
def _is_auto_label(value: str | None) -> bool:
if value is None:
return True
stripped = value.strip()
return stripped == "" or stripped.lower() == "auto"
class DockerWatcher:
"""Watch Docker events and manage terminal sessions dynamically."""
def __init__(
self,
session_manager: SessionManager,
on_container_added: Callable[[str, str, str], None] | None = None,
on_container_removed: Callable[[str], None] | None = None,
socket_path: str | None = None,
) -> None:
"""Initialize Docker watcher.
Args:
session_manager: Session manager for adding/removing apps.
on_container_added: Callback(slug, name, command) when container is added.
on_container_removed: Callback(slug) when container is removed.
socket_path: Docker socket path (default: /var/run/docker.sock).
"""
self._session_manager = session_manager
self._on_container_added = on_container_added
self._on_container_removed = on_container_removed
self._socket_path = socket_path or get_docker_socket_path()
self._running = False
self._task: asyncio.Task | None = None
# Track containers we're managing: slug -> container_id
self._managed_containers: dict[str, str] = {}
async def _docker_request(self, method: str, path: str) -> tuple[int, str]:
"""Make HTTP request to Docker socket.
Returns:
Tuple of (status_code, body).
"""
reader, writer = await asyncio.open_unix_connection(self._socket_path)
try:
request = f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n\r\n"
writer.write(request.encode())
await writer.drain()
# Read status line
status_line = await reader.readline()
status_line_text = status_line.decode("utf-8", errors="replace")
status_code = int(status_line_text.split()[1])
# Read headers
content_length = 0
chunked = False
while True:
line = await reader.readline()
if line == b"\r\n":
break
header = line.decode("utf-8", errors="replace").lower()
if header.startswith("content-length:"):
content_length = int(header.split(":")[1].strip())
if "transfer-encoding: chunked" in header:
chunked = True
# Read body
if chunked:
body_parts = []
while True:
size_line = await reader.readline()
size = int(size_line.decode("utf-8", errors="replace").strip(), 16)
if size == 0:
break
chunk = await reader.readexactly(size)
body_parts.append(chunk)
await reader.readline() # trailing CRLF
body = b"".join(body_parts).decode("utf-8", errors="replace")
elif content_length > 0:
body = (await reader.readexactly(content_length)).decode("utf-8", errors="replace")
else:
body = ""
return status_code, body
finally:
writer.close()
await writer.wait_closed()
async def _get_labeled_containers(self) -> list[dict]:
"""Get all running containers with any webterm label.
Queries for both webterm-command and webterm-theme labels,
merging results and deduplicating by container ID.
"""
seen_ids: set[str] = set()
result: list[dict] = []
for label in WEBTERM_LABELS:
path = f'/containers/json?filters={{"label":["{label}"]}}'
status, body = await self._docker_request("GET", path)
if status != 200:
log.error("Failed to list containers for label %s: %s", label, body)
continue
for container in json.loads(body):
container_id = container.get("Id", "")
if container_id and container_id not in seen_ids:
seen_ids.add(container_id)
result.append(container)
return result
def _get_container_command(self, container: dict) -> str:
"""Get command for container from label.
If label is 'auto', empty, or missing, returns default exec command.
"""
labels = container.get("Labels") or {}
label_value = labels.get(LABEL_NAME)
if _is_auto_label(label_value):
return AUTO_COMMAND_SENTINEL
return label_value or AUTO_COMMAND_SENTINEL
def _get_container_theme(self, container: dict) -> str | None:
labels = container.get("Labels") or {}
value = labels.get(THEME_LABEL)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _get_container_name(self, container: dict) -> str:
"""Get container name (without leading /)."""
names = container.get("Names", [])
if names:
return names[0].lstrip("/")
return container.get("Id", "unknown")[:12]
def _container_to_slug(self, container: dict) -> str:
"""Convert container to URL slug."""
return self._get_container_name(container).replace("_", "-").replace(".", "-")
async def _add_container(self, container: dict) -> None:
"""Add a container as a terminal session."""
slug = self._container_to_slug(container)
name = self._get_container_name(container)
command = self._get_container_command(container)
theme = self._get_container_theme(container)
container_id = container.get("Id", "")
if slug in self._managed_containers:
log.debug("Container %s already managed", name)
return
log.info("Adding container: %s (slug=%s, cmd=%s)", name, slug, command)
self._managed_containers[slug] = container_id
self._session_manager.add_app(name, command, slug, terminal=True, theme=theme)
if self._on_container_added:
self._on_container_added(slug, name, command)
async def _remove_container(self, container_id: str) -> None:
"""Remove a container's terminal session."""
# Find slug by container_id
slug = None
for s, cid in list(self._managed_containers.items()):
if cid == container_id or cid.startswith(container_id):
slug = s
break
if not slug:
return
log.info("Removing container: %s", slug)
del self._managed_containers[slug]
# Close any active session for this slug before removing the app,
# so session cleanup can still look up the app if needed.
route_key = slug
session = self._session_manager.get_session_by_route_key(route_key)
if session:
session_id = self._session_manager.routes.get(route_key)
if session_id:
await self._session_manager.close_session(session_id)
# Remove from session manager's apps
if slug in self._session_manager.apps_by_slug:
app = self._session_manager.apps_by_slug.pop(slug)
if app in self._session_manager.apps:
self._session_manager.apps.remove(app)
if self._on_container_removed:
self._on_container_removed(slug)
async def _watch_events(self) -> None:
"""Watch Docker events stream."""
filters = json.dumps({"event": ["start", "die"], "type": ["container"]})
path = f"/events?filters={filters}"
while self._running:
try:
reader, writer = await asyncio.open_unix_connection(self._socket_path)
try:
request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n"
writer.write(request.encode())
await writer.drain()
# Skip HTTP headers
while True:
line = await reader.readline()
if line == b"\r\n":
break
# Read event stream (chunked encoding)
while self._running:
size_line = await reader.readline()
if not size_line:
break
try:
size = int(size_line.decode("utf-8", errors="replace").strip(), 16)
except ValueError:
continue
if size == 0:
break
chunk = await reader.readexactly(size)
await reader.readline() # trailing CRLF
try:
event = json.loads(chunk.decode("utf-8", errors="replace"))
await self._handle_event(event)
except json.JSONDecodeError:
continue
finally:
writer.close()
await writer.wait_closed()
except Exception as e:
if self._running:
log.warning("Docker event stream error: %s, reconnecting...", e)
await asyncio.sleep(5)
async def _handle_event(self, event: dict) -> None:
"""Handle a Docker event."""
action = event.get("Action", "")
actor = event.get("Actor", {})
container_id = actor.get("ID", "")
if action == "start":
# Get full container info
status, body = await self._docker_request("GET", f"/containers/{container_id}/json")
if status == 200:
container_info = json.loads(body)
# Convert to list format expected by _add_container
# Labels can be None if container has no labels
labels = container_info.get("Config", {}).get("Labels") or {}
container = {
"Id": container_id,
"Names": ["/" + container_info.get("Name", "").lstrip("/")],
"Labels": labels,
}
if _has_webterm_label(labels):
await self._add_container(container)
elif action == "die":
await self._remove_container(container_id)
async def scan_existing(self) -> None:
"""Scan for existing labeled containers and add them."""
containers = await self._get_labeled_containers()
for container in containers:
await self._add_container(container)
log.info("Found %d existing containers with %s label", len(containers), LABEL_NAME)
async def start(self) -> None:
"""Start watching Docker events."""
if self._running:
return
self._running = True
# First scan existing containers
await self.scan_existing()
# Then start watching for new events
self._task = asyncio.create_task(self._watch_events())
log.info("Docker watcher started")
async def stop(self) -> None:
"""Stop watching Docker events."""
self._running = False
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
log.info("Docker watcher stopped")
-53
View File
@@ -1,53 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from time import monotonic
from typing import TYPE_CHECKING
EXIT_POLL_RATE = 5
log = logging.getLogger("webterm")
if TYPE_CHECKING:
from .local_server import LocalServer
class ExitPoller:
"""Monitors the client for an idle state, and exits."""
def __init__(self, client: LocalServer, idle_wait: int) -> None:
self.client = client
self.idle_wait = idle_wait
self._task: asyncio.Task | None = None
self._idle_start_time: float | None = None
def start(self) -> None:
"""Start polling."""
self._task = asyncio.create_task(self.run())
def stop(self) -> None:
"""Stop polling"""
if self._task is not None:
self._task.cancel()
async def run(self) -> None:
"""Run the poller."""
if not self.idle_wait:
return
try:
while True:
await asyncio.sleep(EXIT_POLL_RATE)
is_idle = not self.client.session_manager.sessions
if is_idle:
if self._idle_start_time is not None:
if monotonic() - self._idle_start_time > self.idle_wait:
log.info("Exiting due to --exit-on-idle")
self.client.force_exit()
else:
self._idle_start_time = monotonic()
else:
self._idle_start_time = None
except asyncio.CancelledError:
pass
-11
View File
@@ -1,11 +0,0 @@
import os
SEPARATOR = "-"
IDENTITY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTUVWYZ"
IDENTITY_SIZE = 12
def generate(size: int = IDENTITY_SIZE) -> str:
"""Generate a random identifier."""
alphabet = IDENTITY_ALPHABET
return "".join(alphabet[byte % 31] for byte in os.urandom(size))
File diff suppressed because it is too large Load Diff
-161
View File
@@ -1,161 +0,0 @@
from __future__ import annotations
import asyncio
import os
import selectors
from collections import deque
from dataclasses import dataclass, field
from threading import Event, Thread
@dataclass
class Write:
"""Data in a write queue."""
data: bytes
position: int = 0
done_event: asyncio.Event = field(default_factory=asyncio.Event)
class Poller(Thread):
"""A thread which reads from file descriptors and posts read data to a queue."""
def __init__(self) -> None:
super().__init__()
self._loop: asyncio.AbstractEventLoop | None = None
self._selector = selectors.DefaultSelector()
self._read_queues: dict[int, asyncio.Queue[bytes | None]] = {}
self._write_queues: dict[int, deque[Write]] = {}
self._exit_event = Event()
def add_file(self, file_descriptor: int) -> asyncio.Queue:
"""Add a file descriptor to the poller.
Args:
file_descriptor: File descriptor.
Returns:
Async queue.
"""
self._selector.register(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE)
queue = self._read_queues[file_descriptor] = asyncio.Queue()
return queue
def remove_file(self, file_descriptor: int) -> None:
"""Remove a file descriptor from the poller.
Args:
file_descriptor: File descriptor.
"""
self._selector.unregister(file_descriptor)
self._read_queues.pop(file_descriptor, None)
self._write_queues.pop(file_descriptor, None)
async def write(self, file_descriptor: int, data: bytes) -> None:
"""Write data to a file descriptor.
Args:
file_descriptor: File descriptor.
data: Data to write.
"""
if file_descriptor not in self._write_queues:
self._write_queues[file_descriptor] = deque()
new_write = Write(data)
self._write_queues[file_descriptor].append(new_write)
try:
self._selector.modify(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE)
except KeyError:
# File descriptor removed concurrently
new_write.done_event.set()
return
await new_write.done_event.wait()
async def write_with_timeout(
self, file_descriptor: int, data: bytes, timeout: float = 2.0
) -> bool:
"""Write data to a file descriptor with a timeout.
Args:
file_descriptor: File descriptor.
data: Data to write.
timeout: Maximum seconds to wait for the write to complete.
Returns:
True if the write completed, False on timeout.
"""
try:
await asyncio.wait_for(self.write(file_descriptor, data), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
"""Set the asyncio loop.
Args:
loop: Async loop.
"""
self._loop = loop
def run(self) -> None:
"""Run the Poller thread."""
readable_events = selectors.EVENT_READ
writeable_events = selectors.EVENT_WRITE
loop = self._loop
selector = self._selector
assert loop is not None
while not self._exit_event.is_set():
events = selector.select(1)
for selector_key, event_mask in events:
file_descriptor = selector_key.fileobj
assert isinstance(file_descriptor, int)
queue = self._read_queues.get(file_descriptor, None)
if queue is not None:
if event_mask & readable_events:
try:
data = os.read(file_descriptor, 1024 * 32) or None
except OSError:
loop.call_soon_threadsafe(queue.put_nowait, None)
else:
loop.call_soon_threadsafe(queue.put_nowait, data)
if event_mask & writeable_events:
write_queue = self._write_queues.get(file_descriptor, None)
if write_queue:
# Process all pending writes while fd is writable
while write_queue:
write = write_queue[0]
remaining_data = write.data[write.position :]
try:
bytes_written = os.write(file_descriptor, remaining_data)
except OSError:
# Write failed; signal completion anyway to unblock waiters
write_queue.popleft()
loop.call_soon_threadsafe(write.done_event.set)
break
write.position += bytes_written
# Check if all data has been written
if write.position >= len(write.data):
write_queue.popleft()
loop.call_soon_threadsafe(write.done_event.set)
else:
# Partial write — fd buffer full, try again next cycle
break
else:
selector.modify(file_descriptor, readable_events)
def exit(self) -> None:
"""Exit and block until finished."""
for queue in self._read_queues.values():
queue.put_nowait(None)
self._exit_event.set()
self.join()
self._read_queues.clear()
self._write_queues.clear()
-108
View File
@@ -1,108 +0,0 @@
from __future__ import annotations
from abc import abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import asyncio
from .types import Meta
class SessionConnector:
"""Connect a session with a client."""
async def on_data(self, data: bytes) -> None:
"""Handle data from session.
Args:
data: Bytes to handle.
"""
async def on_meta(self, meta: Meta) -> None:
"""Handle meta from session.
Args:
meta: Mapping of meta information.
"""
async def on_binary_encoded_message(self, payload: bytes) -> None:
"""Handle binary encoded data from the process.
Args:
payload: Binary encoded data to handle.
"""
async def on_close(self) -> None:
"""Handle session close."""
class Session:
"""Virtual base class for a session."""
def __init__(self) -> None:
self._connector = SessionConnector()
@abstractmethod
async def open(self, width: int = 80, height: int = 24) -> None:
"""Open the session."""
...
@abstractmethod
async def start(self, connector: SessionConnector) -> asyncio.Task:
"""Start the session.
Returns:
Running task.
"""
...
@abstractmethod
async def close(self) -> None:
"""Close the session."""
@abstractmethod
async def wait(self) -> None:
"""Wait for session to end."""
@abstractmethod
async def set_terminal_size(self, width: int, height: int) -> None:
"""Set the terminal size.
Args:
width: New width.
height: New height.
"""
...
@abstractmethod
async def send_bytes(self, data: bytes) -> bool:
"""Send bytes to the process.
Args:
data: Bytes to send.
Returns:
True on success, or False if the data was not sent.
"""
...
@abstractmethod
async def send_meta(self, data: Meta) -> bool:
"""Send meta to the process.
Args:
meta: Meta information.
Returns:
True on success, or False if the data was not sent.
"""
...
def is_running(self) -> bool:
"""Check if the session is still running.
Returns:
True if session is active, False otherwise.
"""
return False
-222
View File
@@ -1,222 +0,0 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import shlex
import sys
from typing import TYPE_CHECKING
from . import config, constants
from ._two_way_dict import TwoWayDict
from .docker_exec_session import DockerExecSession, DockerExecSpec
from .docker_watcher import AUTO_COMMAND_SENTINEL, _get_auto_command
from .identity import generate
if TYPE_CHECKING:
from pathlib import Path
from .poller import Poller
from .session import Session
from .types import RouteKey, SessionID
log = logging.getLogger("webterm")
if not constants.WINDOWS:
from .terminal_session import TerminalSession
class SessionManager:
"""Manage terminal sessions."""
def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None:
self.poller = poller
self.path = path
self.apps = apps
self.apps_by_slug = {app.slug: app for app in apps}
self.sessions: dict[SessionID, Session] = {}
self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict()
def add_app(
self,
name: str,
command: str,
slug: str,
terminal: bool = False,
theme: str | None = None,
) -> None:
"""Add a new app
Args:
name: Name of the app.
command: Command to run the app.
slug: Slug used in URL, or blank to auto-generate on server.
"""
slug = slug or generate().lower()
new_app = config.App(
name=name, slug=slug, path="./", command=command, terminal=terminal, theme=theme
)
self.apps.append(new_app)
self.apps_by_slug[slug] = new_app
def get_default_app(self) -> config.App | None:
"""Get the default app (first configured app), or ``None``."""
return self.apps[0] if self.apps else None
def on_session_end(self, session_id: SessionID) -> None:
"""Called when a session ends."""
self.sessions.pop(session_id, None)
route_key = self.routes.get_key(session_id)
if route_key is not None:
with contextlib.suppress(KeyError):
del self.routes[route_key]
log.debug("Session %s ended", session_id)
async def close_all(self, timeout: float = 3.0) -> None:
"""Close app sessions.
Args:
timeout: Time (in seconds) to wait before giving up.
"""
sessions = list(self.sessions.values())
if not sessions:
return
log.info("Closing %s session(s)", len(sessions))
async def do_close() -> int:
"""Close all sessions, return number unclosed after timeout
Returns:
Number of sessions not yet closed.
"""
async def close_wait(session: Session) -> None:
await asyncio.gather(session.close(), session.wait())
if sys.version_info >= (3, 11):
async with asyncio.TaskGroup() as tg: # type: ignore[attr-defined]
for session in sessions:
tg.create_task(close_wait(session))
return 0
_done, remaining = await asyncio.wait(
[asyncio.create_task(close_wait(session)) for session in sessions],
timeout=timeout,
)
return len(remaining)
remaining = await do_close()
if remaining:
log.warning("%s session(s) didn't close after %s seconds", remaining, timeout)
async def new_session(
self,
slug: str,
session_id: SessionID,
route_key: RouteKey,
size: tuple[int, int] = (80, 24),
) -> Session | None:
"""Create a new session.
Args:
slug: Slug for app.
session_id: Session identity.
route_key: Route key.
size: Terminal size (width, height).
Returns:
New session, or `None` if no app / terminal configured.
"""
app = self.apps_by_slug.get(slug)
if app is None:
return None
session_process: Session
if constants.WINDOWS:
log.warning("Sorry, webterm does not currently support terminals on Windows")
return None
if app.command == AUTO_COMMAND_SENTINEL:
docker_user = os.environ.get("WEBTERM_DOCKER_USERNAME")
# Support {container} placeholder in auto command for per-container customization
# e.g., WEBTERM_DOCKER_AUTO_COMMAND="tmux new-session -ADs {container}"
auto_cmd = _get_auto_command().replace("{container}", app.name)
exec_spec = DockerExecSpec(
container=app.name,
command=shlex.split(auto_cmd),
user=docker_user,
)
session_process = DockerExecSession(self.poller, session_id, exec_spec)
else:
session_process = TerminalSession(
self.poller,
session_id,
app.command,
)
log.info("Created terminal session %s", session_id)
# Open the session BEFORE registering it, so it's fully initialized
# when other code can access it via sessions/routes dicts
await session_process.open(*size)
log.debug("Session %s opened and ready", session_id)
# Now register the fully initialized session
self.sessions[session_id] = session_process
self.routes[route_key] = session_id
return session_process
async def close_session(self, session_id: SessionID) -> None:
"""Close a session and remove it from tracking.
Args:
session_id: Session identity.
"""
session_process = self.sessions.get(session_id, None)
if session_process is None:
return
await session_process.close()
self.on_session_end(session_id)
def get_session(self, session_id: SessionID) -> Session | None:
"""Get a session from a session ID.
Args:
session_id: Session identity.
Returns:
A session or `None` if it doesn't exist.
"""
return self.sessions.get(session_id)
def get_session_by_route_key(self, route_key: RouteKey) -> Session | None:
"""Get a session from a route key.
Args:
route_key: A route key.
Returns:
A session or `None` if it doesn't exist.
"""
session_id = self.routes.get(route_key)
if session_id is not None:
return self.sessions.get(session_id)
return None
def get_first_running_session(self) -> tuple[RouteKey, Session] | None:
"""Get the first running session.
Returns:
Tuple of (route_key, session) or None if no running sessions.
"""
for route_key in self.routes:
session_id = self.routes.get(route_key)
if session_id:
session = self.sessions.get(session_id)
if session and session.is_running():
return (route_key, session)
return None
-18
View File
@@ -1,18 +0,0 @@
import re
import unicodedata
def slugify(value: str, allow_unicode=False) -> str:
"""
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
dashes to single dashes. Remove characters that aren't alphanumerics,
underscores, or hyphens. Convert to lowercase. Also strip leading and
trailing whitespace, dashes, and underscores.
"""
value = str(value)
if allow_unicode:
value = unicodedata.normalize("NFKC", value)
else:
value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
value = re.sub(r"[^\w\s-]", "", value.lower())
return re.sub(r"[-\s]+", "-", value).strip("-_")
View File
-314
View File
@@ -1,314 +0,0 @@
"""Custom SVG exporter for terminal screenshots.
Generates SVG directly from pyte screen buffer, avoiding Rich's export_svg() quirks.
"""
from __future__ import annotations
import html
from typing import TypedDict
# ANSI color names to hex values (standard 16-color palette)
ANSI_COLORS: dict[str, str] = {
# Normal colors
"black": "#000000",
"red": "#cc0000",
"green": "#4e9a06",
"yellow": "#c4a000",
"blue": "#3465a4",
"magenta": "#75507b",
"cyan": "#06989a",
"white": "#d3d7cf",
# Bright colors
"brightblack": "#555753",
"brightred": "#ef2929",
"brightgreen": "#8ae234",
"brightyellow": "#fce94f",
"brightblue": "#729fcf",
"brightmagenta": "#ad7fa8",
"brightcyan": "#34e2e2",
"brightwhite": "#eeeeec",
# Alternative names
"gray": "#555753",
"grey": "#555753",
"lightgray": "#d3d7cf",
"lightgrey": "#d3d7cf",
"brown": "#c4a000",
}
# Default colors
DEFAULT_FG = "#d3d7cf"
DEFAULT_BG = "#000000"
# Font settings
FONT_FAMILY = (
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", '
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", '
'"DejaVu Sans Mono", "Courier New", monospace'
)
FONT_SIZE = 14
LINE_HEIGHT = 1.2
CHAR_WIDTH = 8 # Width of monospace character at 14px (typically ~0.57 ratio)
# Box drawing characters that need vertical scaling to fill line height
# These are designed to connect between lines but the font's em-box is smaller
# than our line height, creating gaps
BOX_DRAWING_CHARS = frozenset(
# Light and heavy box drawing (U+2500-U+257F)
"─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋"
# Double box drawing
"═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬"
# Rounded corners
"╭╮╯╰"
# Light and heavy dashed (U+2571-U+257F)
"\u2571\u2572\u2573╴╵╶╷╸╹╺╻╼╽╾╿"
)
def _is_box_drawing(char: str) -> bool:
"""Check if character is a box-drawing character that needs scaling."""
return len(char) == 1 and char in BOX_DRAWING_CHARS
class CharData(TypedDict):
"""Character data from pyte screen buffer."""
data: str
fg: str
bg: str
bold: bool
italics: bool
underscore: bool
reverse: bool
def _normalize_palette(palette: dict[str, str]) -> dict[str, str]:
normalized = {key.lower(): value for key, value in palette.items()}
normalized.setdefault("gray", normalized.get("brightblack", ANSI_COLORS["gray"]))
normalized.setdefault("grey", normalized.get("brightblack", ANSI_COLORS["grey"]))
normalized.setdefault("lightgray", normalized.get("white", ANSI_COLORS["lightgray"]))
normalized.setdefault("lightgrey", normalized.get("white", ANSI_COLORS["lightgrey"]))
normalized.setdefault("brown", normalized.get("yellow", ANSI_COLORS["brown"]))
return normalized
def _color_to_hex(
color: str,
is_foreground: bool = True,
*,
palette: dict[str, str] | None = None,
default_fg: str = DEFAULT_FG,
default_bg: str = DEFAULT_BG,
) -> str:
"""Convert pyte color to hex value."""
if color == "default":
return default_fg if is_foreground else default_bg
# Already a hex color with #
if color.startswith("#"):
return color
# Hex color without # prefix (pyte's 256-color/truecolor format)
# Check if it looks like a hex color (6 hex digits)
if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color):
return f"#{color}"
# Named color lookup (case-insensitive)
lower = color.lower()
palette_map = palette if palette is not None else ANSI_COLORS
if lower in palette_map:
return palette_map[lower]
# RGB format "rgb(r,g,b)" - rarely used but handle it
if lower.startswith("rgb("):
# Not common in terminal output, return default
return default_fg if is_foreground else default_bg
return default_fg if is_foreground else default_bg
def _escape_xml(text: str) -> str:
"""Escape special XML characters."""
return html.escape(text, quote=True)
def render_terminal_svg(
screen_buffer: list[list[CharData]],
width: int,
height: int,
*,
title: str = "Terminal",
font_size: int = FONT_SIZE,
char_width: float = CHAR_WIDTH,
line_height: float = LINE_HEIGHT,
background: str = DEFAULT_BG,
foreground: str = DEFAULT_FG,
palette: dict[str, str] | None = None,
) -> str:
"""Render terminal screen buffer to SVG.
Args:
screen_buffer: 2D list of CharData dicts from pyte
width: Terminal width in columns
height: Terminal height in rows
title: SVG title (for accessibility)
font_size: Font size in pixels
char_width: Width of a single character
line_height: Line height multiplier
background: Background color
foreground: Default foreground color
Returns:
SVG string
"""
# Calculate dimensions
actual_line_height = font_size * line_height
svg_width = width * char_width + 20 # Add padding
svg_height = height * actual_line_height + 20
# Start building SVG
parts: list[str] = []
parts.append(
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {svg_width:.1f} {svg_height:.1f}" '
f'class="terminal-svg">'
)
parts.append(f"<title>{_escape_xml(title)}</title>")
# Style definitions
# Note: We use alphabetic baseline (default) and offset text y by font_size
# to align text top with rect top. This is more compatible across browsers
# than dominant-baseline: text-before-edge which has Safari issues.
parts.append("<defs><style>")
parts.append(
f".terminal-bg {{ fill: {background}; }}"
f".terminal-text {{ "
f"font-family: {FONT_FAMILY}; "
f"font-size: {font_size}px; "
f"fill: {foreground}; "
f"white-space: pre; "
f"text-rendering: optimizeLegibility; "
f"}}"
f".bold {{ font-weight: bold; }}"
f".italic {{ font-style: italic; }}"
f".underline {{ text-decoration: underline; }}"
)
parts.append("</style></defs>")
# Background rectangle
parts.append(
f'<rect class="terminal-bg" x="0" y="0" width="{svg_width:.1f}" height="{svg_height:.1f}"/>'
)
# Text content group
parts.append('<g class="terminal-text">')
# Render each row - use explicit x position for EACH character
# to ensure pixel-perfect alignment regardless of font metrics
palette_map = _normalize_palette(palette) if palette is not None else ANSI_COLORS
for row_idx, row_data in enumerate(screen_buffer):
# rect_y is the top of the cell
rect_y = 10 + row_idx * actual_line_height
# text_y is the baseline position (alphabetic baseline = bottom of lowercase letters)
# For most fonts, baseline is roughly at font_size from top of em box
text_y = rect_y + font_size
if not row_data:
continue
# Collect background rects and text spans
row_bg_rects: list[str] = []
row_tspans: list[str] = []
# Track current style for potential span merging (only merge if same style AND adjacent)
col = 0
while col < len(row_data):
char = row_data[col]
char_data = char["data"]
# Skip empty placeholder cells (after wide characters)
if not char_data:
col += 1
continue
x = 10.0 + col * char_width
# Get colors, handling reverse video
fg = _color_to_hex(
char["fg"],
is_foreground=True,
palette=palette_map,
default_fg=foreground,
default_bg=background,
)
bg = _color_to_hex(
char["bg"],
is_foreground=False,
palette=palette_map,
default_fg=foreground,
default_bg=background,
)
if char["reverse"]:
fg, bg = bg, fg
# Count columns for this character (wide chars take 2)
char_cols = 1
if col + 1 < len(row_data) and not row_data[col + 1]["data"]:
char_cols = 2 # Wide character
# Background rect if not default
# Add 0.5px overlap in both directions to eliminate sub-pixel gaps at high zoom
if bg != background:
bg_width = char_cols * char_width + 0.5
row_bg_rects.append(
f'<rect x="{x:.1f}" y="{rect_y:.1f}" '
f'width="{bg_width:.1f}" height="{actual_line_height + 0.5:.1f}" '
f'fill="{bg}"/>'
)
# Build tspan with explicit x position
attrs = [f'x="{x:.1f}"']
if fg != foreground:
attrs.append(f'fill="{fg}"')
classes = []
if char["bold"]:
classes.append("bold")
if char["italics"]:
classes.append("italic")
if char["underscore"]:
classes.append("underline")
if classes:
attrs.append(f'class="{" ".join(classes)}"')
# Box-drawing characters need vertical scaling to fill line height
# Render them as separate text elements with transform
if _is_box_drawing(char_data):
# Scale vertically by line_height ratio, anchored at top of cell
# The transform scales around (x, rect_y) to stretch the glyph
fill_attr = f' fill="{fg}"' if fg != foreground else ""
class_attr = f' class="{" ".join(classes)}"' if classes else ""
row_bg_rects.append(
f'<text x="{x:.1f}" y="{text_y:.1f}" '
f'transform="translate(0,{rect_y:.1f}) scale(1,{line_height}) translate(0,{-rect_y:.1f})"'
f"{fill_attr}{class_attr}>{_escape_xml(char_data)}</text>"
)
else:
row_tspans.append(f"<tspan {' '.join(attrs)}>{_escape_xml(char_data)}</tspan>")
col += char_cols
# Add background rects first, then text
if row_bg_rects or row_tspans:
parts.extend(row_bg_rects)
if row_tspans:
parts.append(f'<text y="{text_y:.1f}">')
parts.extend(row_tspans)
parts.append("</text>")
parts.append("</g>")
parts.append("</svg>")
return "".join(parts)
-487
View File
@@ -1,487 +0,0 @@
from __future__ import annotations
import array
import asyncio
import contextlib
import fcntl
import logging
import os
import pty
import re
import shlex
import signal
import termios
from collections import deque
from typing import TYPE_CHECKING
import pyte
from importlib_metadata import PackageNotFoundError, version
from .alt_screen import AltScreen
from .session import Session, SessionConnector
if TYPE_CHECKING:
from .poller import Poller
from .types import Meta, SessionID
log = logging.getLogger("webterm")
# Maximum bytes to keep in replay buffer for reconnection
REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB
# Default screen size for pyte emulator
DEFAULT_SCREEN_WIDTH = 132
DEFAULT_SCREEN_HEIGHT = 45
# Pattern to filter out terminal device attribute responses that cause display issues
# These are responses to DA1/DA2/DA3 queries that shouldn't be displayed as text
# Matches complete responses like:
# \x1b[?1;10;0c (DA1 - Primary Device Attributes)
# \x1b[>1;10;0c (DA2 - Secondary Device Attributes, sent by tmux)
# \x1b[=1;0c (DA3 - Tertiary Device Attributes)
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c")
# Pattern to detect partial DA responses at end of data (incomplete escape sequence)
# Matches: \x1b, \x1b[, \x1b[?, \x1b[>, \x1b[=, \x1b[?1, \x1b[>1;10, etc.
DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:[?>=][\d;]*)?)?$")
# Map C1 control sequences to 7-bit ESC equivalents for pyte compatibility
CSI_C1 = b"\x9b"
OSC_C1 = b"\x9d"
ST_C1 = b"\x9c"
DCS_C1 = b"\x90"
SOS_C1 = b"\x98"
PM_C1 = b"\x9e"
APC_C1 = b"\x9f"
def _normalize_c1_controls(data: bytes, utf8_buffer: bytes = b"") -> tuple[bytes, bytes]:
if not data and not utf8_buffer:
return b"", b""
data = utf8_buffer + data
out = bytearray()
pending_utf8 = bytearray()
expected_continuations = 0
c1_map = {
0x9B: b"\x1b[",
0x9D: b"\x1b]",
0x9C: b"\x1b\\",
0x90: b"\x1bP",
0x98: b"\x1bX",
0x9E: b"\x1b^",
0x9F: b"\x1b_",
}
idx = 0
while idx < len(data):
byte = data[idx]
if expected_continuations:
if 0x80 <= byte <= 0xBF:
pending_utf8.append(byte)
expected_continuations -= 1
idx += 1
if expected_continuations == 0:
out.extend(pending_utf8)
pending_utf8.clear()
continue
out.extend(pending_utf8)
pending_utf8.clear()
expected_continuations = 0
continue
if 0xC2 <= byte <= 0xDF:
pending_utf8.append(byte)
expected_continuations = 1
idx += 1
continue
if 0xE0 <= byte <= 0xEF:
pending_utf8.append(byte)
expected_continuations = 2
idx += 1
continue
if 0xF0 <= byte <= 0xF4:
pending_utf8.append(byte)
expected_continuations = 3
idx += 1
continue
replacement = c1_map.get(byte)
if replacement is not None:
out.extend(replacement)
else:
out.append(byte)
idx += 1
if pending_utf8:
return bytes(out), bytes(pending_utf8)
return bytes(out), b""
class TerminalSession(Session):
"""A session that manages a terminal."""
def __init__(
self,
poller: Poller,
session_id: SessionID,
command: str,
) -> None:
self.poller = poller
self.session_id = session_id
self.command = command or os.environ.get("SHELL", "sh")
self.master_fd: int | None = None
self.pid: int | None = None
self._task: asyncio.Task | None = None
self._replay_buffer: deque[bytes] = deque()
self._replay_buffer_size = 0
self._replay_lock = asyncio.Lock()
# pyte screen for accurate terminal state tracking (AltScreen for alternate buffer support)
self._screen = AltScreen(DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT)
self._stream = pyte.ByteStream(self._screen)
self._screen_lock = asyncio.Lock()
# Track last known terminal size for reconnection
self._last_width = DEFAULT_SCREEN_WIDTH
self._last_height = DEFAULT_SCREEN_HEIGHT
# Change counter for reliable activity detection (monotonically increasing)
self._change_counter = 0
self._last_snapshot_counter = 0
# Buffer for handling escape sequences split across reads
self._escape_buffer = b""
self._utf8_buffer = b""
super().__init__()
def __repr__(self) -> str:
return f"TerminalSession(session_id={self.session_id!r}, command={self.command!r})"
@staticmethod
def _package_version() -> str:
try:
return version("webterm")
except PackageNotFoundError:
return "0.0.0"
async def open(self, width: int = 80, height: int = 24) -> None:
log.info("Opening terminal session %s with command: %s", self.session_id, self.command)
# Track the initial size
self._last_width = width
self._last_height = height
# Initialize pyte screen with the requested size (under lock to prevent races)
async with self._screen_lock:
self._screen = AltScreen(width, height)
self._stream = pyte.ByteStream(self._screen)
pid, master_fd = pty.fork()
self.pid = pid
self.master_fd = master_fd
if pid == pty.CHILD:
os.environ["TERM_PROGRAM"] = "webterm"
os.environ["TERM_PROGRAM_VERSION"] = self._package_version()
try:
argv = shlex.split(self.command)
except ValueError:
os._exit(1)
if not argv:
os._exit(1)
try:
os.execvp(argv[0], argv) ## Exits the app
except OSError:
os._exit(1)
try:
self._set_terminal_size(width, height)
except OSError:
# Clean up on failure
os.close(master_fd)
self.master_fd = None
raise
log.debug("Terminal session %s opened successfully", self.session_id)
def _set_terminal_size(self, width: int, height: int) -> None:
buf = array.array("h", [height, width, 0, 0])
assert self.master_fd is not None
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, buf)
def _get_terminal_size(self) -> tuple[int, int]:
"""Get actual PTY size. Returns (width, height)."""
assert self.master_fd is not None
buf = array.array("h", [0, 0, 0, 0])
fcntl.ioctl(self.master_fd, termios.TIOCGWINSZ, buf)
return (buf[1], buf[0]) # cols, rows
async def _sync_pyte_to_pty(self) -> None:
"""Sync pyte screen size to actual PTY size."""
if self.master_fd is None:
return
loop = asyncio.get_running_loop()
# Hold lock during PTY read to ensure consistency with concurrent set_terminal_size
async with self._screen_lock:
width, height = await loop.run_in_executor(None, self._get_terminal_size)
if self._screen.columns != width or self._screen.lines != height:
log.debug(
"Syncing pyte screen from %dx%d to %dx%d",
self._screen.columns,
self._screen.lines,
width,
height,
)
self._screen.resize(height, width)
self._last_width = width
self._last_height = height
async def set_terminal_size(self, width: int, height: int) -> None:
"""Set terminal size."""
loop = asyncio.get_running_loop()
# Hold lock during PTY write to ensure consistency with concurrent _sync_pyte_to_pty
async with self._screen_lock:
self._last_width = width
self._last_height = height
await loop.run_in_executor(None, self._set_terminal_size, width, height)
# Resize pyte screen to match
self._screen.resize(height, width)
# Increment change counter since resize changes screen content
self._change_counter += 1
async def force_redraw(self) -> None:
"""Force a terminal redraw by re-sending current size."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None, self._set_terminal_size, self._last_width, self._last_height
)
async def _add_to_replay_buffer(self, data: bytes) -> None:
"""Add data to replay buffer, maintaining size limit."""
async with self._replay_lock:
self._replay_buffer.append(data)
self._replay_buffer_size += len(data)
while self._replay_buffer_size > REPLAY_BUFFER_SIZE and self._replay_buffer:
old_data = self._replay_buffer.popleft()
self._replay_buffer_size -= len(old_data)
async def _update_screen(self, data: bytes) -> None:
"""Update the pyte screen with new terminal data."""
async with self._screen_lock:
try:
normalized, self._utf8_buffer = _normalize_c1_controls(data, self._utf8_buffer)
if not normalized:
return
normalized = self._screen.expand_clear_sequences(normalized)
self._stream.feed(normalized)
# Increment change counter when screen is modified
if self._screen.dirty:
self._change_counter += 1
except Exception as exc:
# Don't let pyte errors crash the session
log.warning(
"Terminal screen update failed (%s): %s",
type(exc).__name__,
exc,
)
async def get_replay_buffer(self) -> bytes:
"""Get the contents of the replay buffer."""
async with self._replay_lock:
return b"".join(self._replay_buffer)
async def get_screen_lines(self) -> list[str]:
"""Get the current screen state as a list of lines.
Returns properly rendered terminal content with all escape sequences
interpreted, suitable for screenshot generation.
"""
async with self._screen_lock:
return [line.rstrip() for line in self._screen.display]
async def get_screen_has_changes(self) -> bool:
"""Check if the screen has changed since the last snapshot.
This is a non-mutating read-only check that compares the change counter.
"""
async with self._screen_lock:
return self._change_counter > self._last_snapshot_counter
async def get_screen_snapshot(self) -> tuple[int, int, list, bool]:
"""Get a read-only snapshot of the current screen state for screenshots.
This method does NOT mutate terminal state - safe for dashboard screenshots.
Returns:
Tuple of (width, height, buffer, has_changes) where:
- width: screen width in columns
- height: screen height in rows
- buffer: list of rows, each containing character data with styling
- has_changes: True if screen has changed since last snapshot
"""
async with self._screen_lock:
width = self._screen.columns
height = self._screen.lines
has_changes = self._change_counter > self._last_snapshot_counter
# Update the snapshot counter to track what we've seen
self._last_snapshot_counter = self._change_counter
# Snapshot buffer cells quickly to minimize lock hold time
snapshot = [
[self._screen.buffer[row][col] for col in range(width)] for row in range(height)
]
buffer = []
for row_data in snapshot:
row_chars = []
for char in row_data:
row_chars.append(
{
"data": char.data if char.data else " ",
"fg": char.fg,
"bg": char.bg,
"bold": char.bold,
"italics": char.italics,
"underscore": char.underscore,
"reverse": char.reverse,
}
)
buffer.append(row_chars)
return (width, height, buffer, has_changes)
async def get_screen_state(self) -> tuple[int, int, list, bool]:
"""Get the current screen state including dimensions and character buffer.
Note: This method syncs pyte to PTY size and clears dirty flags.
For read-only screenshot access, use get_screen_snapshot() instead.
Returns:
Tuple of (width, height, buffer, has_changes) where:
- width: screen width in columns
- height: screen height in rows
- buffer: list of rows, each containing character data with styling
- has_changes: True if screen has changed since last call
"""
# Sync pyte to actual PTY size before reading state
await self._sync_pyte_to_pty()
async with self._screen_lock:
width = self._screen.columns
height = self._screen.lines
# Check if any rows are dirty (changed since last clear)
has_changes = len(self._screen.dirty) > 0
# Clear dirty set after checking
self._screen.dirty.clear()
# Snapshot buffer cells quickly to minimize lock hold time
snapshot = [
[self._screen.buffer[row][col] for col in range(width)] for row in range(height)
]
buffer = []
for row_data in snapshot:
row_chars = []
for char in row_data:
row_chars.append(
{
"data": char.data if char.data else " ",
"fg": char.fg,
"bg": char.bg,
"bold": char.bold,
"italics": char.italics,
"underscore": char.underscore,
"reverse": char.reverse,
}
)
buffer.append(row_chars)
return (width, height, buffer, has_changes)
def update_connector(self, connector: SessionConnector) -> None:
"""Update the connector for reconnection without restarting the session."""
self._connector = connector
log.debug("Updated connector for session %s", self.session_id)
async def start(self, connector: SessionConnector) -> asyncio.Task:
self._connector = connector
assert self.master_fd is not None
if self._task is not None:
# Already running, just update connector (handled by update_connector)
return self._task
self._task = asyncio.create_task(self.run())
return self._task
async def run(self) -> None:
assert self.master_fd is not None
queue = self.poller.add_file(self.master_fd)
try:
while True:
data = await queue.get()
if not data:
break
# Prepend any buffered partial escape sequence from previous read
if self._escape_buffer:
data = self._escape_buffer + data
self._escape_buffer = b""
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
data = DA_RESPONSE_PATTERN.sub(b"", data)
if not data:
continue
# Check for partial escape sequence at end that might be a DA response
# Hold it back until we get more data to see if it completes
match = DA_PARTIAL_PATTERN.search(data)
if match:
self._escape_buffer = data[match.start() :]
data = data[: match.start()]
if not data:
continue
# Store in replay buffer for reconnection
await self._add_to_replay_buffer(data)
# Update pyte screen state for screenshots
await self._update_screen(data)
# Send to current connector
if self._connector:
await self._connector.on_data(data)
except OSError:
log.exception("error in terminal.run")
finally:
if self._connector:
await self._connector.on_close()
if self.master_fd is not None:
fd = self.master_fd
self.master_fd = None
# Remove from poller first (while fd is still valid), then close
self.poller.remove_file(fd)
os.close(fd)
async def send_bytes(self, data: bytes) -> bool:
fd = self.master_fd
if fd is None:
return False
try:
await self.poller.write(fd, data)
except (KeyError, OSError):
return False
return True
async def send_meta(self, data: Meta) -> bool:
return True
async def close(self) -> None:
# Cancel the read task first to unblock any waiting queue.get()
if self._task is not None and not self._task.done():
self._task.cancel()
if self.pid is not None:
try:
os.kill(self.pid, signal.SIGHUP)
except ProcessLookupError:
pass # Process already gone
except Exception as e:
log.warning("Error closing terminal session %s: %s", self.session_id, e)
async def wait(self, timeout: float = 2.0) -> None:
if self._task is not None:
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
await asyncio.wait_for(asyncio.shield(self._task), timeout=timeout)
def is_running(self) -> bool:
"""Check if the terminal session is still running."""
if self.master_fd is None or self._task is None:
return False
# Check if process is actually alive
if self.pid is not None:
try:
os.kill(self.pid, 0) # Signal 0 checks existence
return True
except OSError:
return False
# pid is None means process not started or already exited
return False
-6
View File
@@ -1,6 +0,0 @@
from typing import NewType, Union
AppID = NewType("AppID", str)
Meta = dict[str, Union[str, None, int, bool]]
RouteKey = NewType("RouteKey", str)
SessionID = NewType("SessionID", str)
-1
View File
@@ -1 +0,0 @@
"""Tests for webterm."""
-166
View File
@@ -1,166 +0,0 @@
"""Pytest configuration and fixtures for webterm tests."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
import pytest
from webterm.config import App, Config
from webterm.local_server import LocalServer
from webterm.poller import Poller
from webterm.session_manager import SessionManager
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
@pytest.fixture
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
"""Create an event loop for async tests."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def sample_terminal_app() -> App:
"""Create a sample terminal app configuration."""
return App(
name="Test Terminal",
slug="test-terminal",
terminal=True,
command="echo hello",
)
@pytest.fixture
def sample_config(sample_terminal_app: App) -> Config:
"""Create a sample configuration with a terminal app."""
return Config(apps=[sample_terminal_app])
@pytest.fixture
def tmp_config_path(tmp_path: Path) -> Path:
"""Create a temporary config path."""
return tmp_path / "config"
@pytest.fixture
def mock_request() -> MagicMock:
"""Create a mock request with common attributes."""
request = MagicMock()
request.headers = {}
request.secure = False
request.query = {}
return request
@pytest.fixture
def screen_buffer_factory():
def _make(rows: list[str], width: int = 80):
return [
[
{
"data": c,
"fg": "default",
"bg": "default",
"bold": False,
"italics": False,
"underscore": False,
"reverse": False,
}
for c in (row + " " * width)[:width]
]
for row in rows
]
return _make
@pytest.fixture
def mock_session():
session = MagicMock()
session.get_screen_has_changes = AsyncMock(return_value=False)
session.get_screen_state = AsyncMock(return_value=(80, 24, [], True))
session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], True))
return session
@pytest.fixture
def poller() -> Poller:
"""Create a Poller instance."""
return Poller()
@pytest.fixture
def mock_poller() -> MagicMock:
"""Create a mock Poller for unit tests."""
return MagicMock()
class DummyAsyncLock:
"""A dummy async context manager for replacing locks in tests."""
async def __aenter__(self):
return None
async def __aexit__(self, exc_type, exc, tb):
return False
@pytest.fixture
def dummy_lock() -> DummyAsyncLock:
"""Create a dummy async lock for tests."""
return DummyAsyncLock()
@pytest.fixture
def mock_screen_char():
"""Factory for creating mock pyte screen characters."""
def _make(
data: str = " ",
fg: int = 0,
bg: int = 0,
bold: bool = False,
italics: bool = False,
underscore: bool = False,
reverse: bool = False,
) -> MagicMock:
char = MagicMock()
char.data = data
char.fg = fg
char.bg = bg
char.bold = bold
char.italics = italics
char.underscore = underscore
char.reverse = reverse
return char
return _make
@pytest.fixture
def session_manager(poller: Poller, tmp_path: Path, sample_terminal_app: App) -> SessionManager:
"""Create a SessionManager instance."""
return SessionManager(poller, tmp_path, [sample_terminal_app])
@pytest.fixture
async def local_server(
tmp_config_path: Path, sample_config: Config
) -> AsyncGenerator[LocalServer, None]:
"""Create a LocalServer instance for testing."""
server = LocalServer(
str(tmp_config_path),
sample_config,
host="127.0.0.1",
port=0, # Use random available port
)
yield server
# Cleanup
server.force_exit()
-301
View File
@@ -1,301 +0,0 @@
"""Tests for the AltScreen class with alternate screen buffer support."""
import pyte
import pytest
from webterm.alt_screen import DECALTBUF, DECALTBUF_1047, DECALTBUF_1048, AltScreen
class TestAltScreen:
"""Tests for AltScreen alternate buffer support."""
def test_basic_screen_operations(self):
"""Test that basic screen operations still work."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
stream.feed("Hello World\r\n")
stream.feed("Line 2")
assert "Hello World" in screen.display[0]
assert "Line 2" in screen.display[1]
def test_alternate_screen_save_restore(self):
"""Test DECSET/DECRST 1049 saves and restores main screen."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
# Write to main screen
stream.feed("MAIN SCREEN LINE 1\r\n")
stream.feed("MAIN SCREEN LINE 2\r\n")
assert "MAIN SCREEN LINE 1" in screen.display[0]
assert "MAIN SCREEN LINE 2" in screen.display[1]
# Enter alternate screen (DECSET 1049)
stream.feed("\x1b[?1049h")
# Screen should be cleared
assert screen.display[0].strip() == ""
assert screen.display[1].strip() == ""
# Write to alternate screen
stream.feed("ALT SCREEN CONTENT\r\n")
assert "ALT SCREEN CONTENT" in screen.display[0]
# Exit alternate screen (DECRST 1049)
stream.feed("\x1b[?1049l")
# Main screen should be restored
assert "MAIN SCREEN LINE 1" in screen.display[0]
assert "MAIN SCREEN LINE 2" in screen.display[1]
def test_alternate_screen_mode_flag(self):
"""Test that DECALTBUF mode flag is set correctly."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
assert DECALTBUF not in screen.mode
stream.feed("\x1b[?1049h")
assert DECALTBUF in screen.mode
stream.feed("\x1b[?1049l")
assert DECALTBUF not in screen.mode
@pytest.mark.parametrize(
("enter_seq", "exit_seq", "mode_flag"),
[
("\x1b[?1047h", "\x1b[?1047l", DECALTBUF_1047),
("\x1b[?1048h", "\x1b[?1048l", DECALTBUF_1048),
],
)
def test_alternate_screen_mode_variants(self, enter_seq, exit_seq, mode_flag):
"""Test that DECSET/DECRST 1047/1048 trigger alternate buffer handling."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
stream.feed("MAIN SCREEN\r\n")
assert "MAIN SCREEN" in screen.display[0]
stream.feed(enter_seq)
assert mode_flag in screen.mode
assert screen.display[0].strip() == ""
stream.feed("ALT BUFFER\r\n")
assert "ALT BUFFER" in screen.display[0]
stream.feed(exit_seq)
assert mode_flag not in screen.mode
assert "MAIN SCREEN" in screen.display[0]
def test_multiple_alt_screen_switches(self):
"""Test multiple switches between main and alternate screen."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
# Main content
stream.feed("MAIN 1\r\n")
stream.feed("\x1b[?1049h") # Enter alt
stream.feed("ALT 1\r\n")
stream.feed("\x1b[?1049l") # Exit alt
assert "MAIN 1" in screen.display[0]
# More main content
stream.feed("MAIN 2\r\n")
stream.feed("\x1b[?1049h") # Enter alt again
assert screen.display[0].strip() == "" # Alt screen is clear
stream.feed("\x1b[?1049l") # Exit alt
assert "MAIN 1" in screen.display[0]
assert "MAIN 2" in screen.display[1]
def test_resize_invalidates_saved_buffer(self):
"""Test that resizing clears the saved alternate screen buffer."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
stream.feed("MAIN CONTENT\r\n")
stream.feed("\x1b[?1049h") # Enter alt
assert screen._saved_buffer is not None
# Resize while in alt mode
screen.resize(20, 80)
assert screen._saved_buffer is None
def test_ed_clear_still_works(self):
"""Test that explicit ED (erase display) still works."""
screen = AltScreen(40, 10)
stream = pyte.Stream(screen)
stream.feed("Line 1\r\n")
stream.feed("Line 2\r\n")
stream.feed("\x1b[2J") # ED 2 - erase entire display
assert all(line.strip() == "" for line in screen.display)
class TestExpandClearSequences:
"""Tests for expand_clear_sequences (Ink partial clear fix)."""
def test_no_clear_sequences(self):
"""Data without EL2+CUU1 runs is returned unchanged."""
screen = AltScreen(80, 24)
data = b"Hello world\r\n"
assert screen.expand_clear_sequences(data) == data
def test_short_clear_not_expanded(self):
"""Runs of fewer than 3 EL2+CUU1 pairs are not modified."""
screen = AltScreen(80, 24)
data = b"\x1b[2K\x1b[1A\x1b[2K\x1b[1A" # 2 pairs
assert screen.expand_clear_sequences(data) == data
def test_full_clear_not_expanded(self):
"""A clear that already reaches row 0 is not extended."""
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Put cursor at row 5
stream.feed(b"\r\n" * 5)
assert screen.cursor.y == 5
# 5-pair clear already covers rows 5 down to 0
data = b"\x1b[2K\x1b[1A" * 5
result = screen.expand_clear_sequences(data)
assert result == data
def test_partial_clear_is_extended(self):
"""A partial clear that doesn't reach row 0 gets extended."""
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Draw content to push cursor to row 20
for i in range(20):
stream.feed(f"Line {i}\r\n".encode())
assert screen.cursor.y == 20
# Only clear 5 lines (should extend to clear all 20)
data = b"\x1b[2K\x1b[1A" * 5
result = screen.expand_clear_sequences(data)
expected_pairs = 20 # extend from 5 to 20
assert result.count(b"\x1b[2K\x1b[1A") == expected_pairs
def test_partial_clear_produces_correct_screen(self):
"""Simulates Ink /clear: partial clear + redraw leaves clean screen."""
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Draw 15 lines of content (Ink frame 1)
for i in range(15):
stream.feed(f"Old line {i}\r\n".encode())
# Ink /clear: only clears 5 lines then redraws fresh prompt
clear = b"\x1b[2K\x1b[1A" * 5 + b"\x1b[2K\x1b[G"
new_content = b"Fresh prompt\r\n"
expanded = screen.expand_clear_sequences(clear)
stream.feed(expanded)
stream.feed(new_content)
# Old content should be gone
non_empty = [line.rstrip() for line in screen.display if line.strip()]
assert len(non_empty) == 1
assert non_empty[0] == "Fresh prompt"
def test_data_around_clear_preserved(self):
"""Text before and after a clear run is preserved."""
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
stream.feed(b"\r\n" * 10)
data = b"before\x1b[2K\x1b[1A" * 0 + b"before" + b"\x1b[2K\x1b[1A" * 5 + b"after"
result = screen.expand_clear_sequences(data)
assert result.startswith(b"before")
assert result.endswith(b"after")
class TestScrollUpDown:
"""Tests for CSI S (SU) and CSI T (SD) support."""
def test_scroll_up_basic(self):
"""CSI S scrolls content up, adding blank lines at bottom."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
for i in range(10):
stream.feed(f"Line {i}\r\n".encode())
# After writing, Line 0 already scrolled off; rows 0-8 have Lines 1-9
# Scroll up 3 more lines
stream.feed(b"\x1b[3S")
assert "Line 4" in screen.display[0]
assert "Line 9" in screen.display[5]
assert screen.display[6].strip() == ""
def test_scroll_up_default_one(self):
"""CSI S with no parameter defaults to 1 line."""
screen = AltScreen(40, 5)
stream = pyte.ByteStream(screen)
for i in range(5):
stream.feed(f"L{i}\r\n".encode())
stream.feed(b"\x1b[S")
assert "L1" in screen.display[0]
def test_scroll_down_basic(self):
"""CSI T scrolls content down, adding blank lines at top."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
for i in range(10):
stream.feed(f"Line {i}\r\n".encode())
stream.feed(b"\x1b[3T")
assert screen.display[0].strip() == ""
assert screen.display[2].strip() == ""
assert "Line 1" in screen.display[3]
def test_scroll_up_with_margins(self):
"""SU respects the scroll region set by DECSTBM."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
for i in range(10):
stream.feed(f"Row {i}\r\n".encode())
# After writing, rows 0-8 have Row 1..Row 9
# Set scroll region to rows 3-7 (1-based: 4;8)
stream.feed(b"\x1b[4;8r")
stream.feed(b"\x1b[2S")
# Rows outside the region should be unchanged
assert "Row 1" in screen.display[0]
assert "Row 2" in screen.display[1]
assert "Row 3" in screen.display[2]
# Rows inside the region shifted up by 2
assert "Row 6" in screen.display[3]
def test_scroll_up_clears_ghost_content(self):
"""Simulates tmux sending SU during Ink /clear — ghost content is eliminated."""
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Fill screen with "old" content
for i in range(24):
stream.feed(f"Old line {i}\r\n".encode())
non_empty_before = sum(1 for line in screen.display if line.strip())
assert non_empty_before > 15
# Simulate tmux clear: set margins, scroll up, reset margins
stream.feed(b"\x1b[1;23r") # Set scroll region
stream.feed(b"\x1b[20S") # Scroll up 20 lines
stream.feed(b"\x1b[r") # Reset scroll region
non_empty_after = sum(1 for line in screen.display if line.strip())
assert non_empty_after <= 5, f"Expected <= 5 non-empty lines, got {non_empty_after}"
def test_scroll_up_cursor_unchanged(self):
"""SU does not move the cursor position."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
stream.feed(b"\x1b[5;10H") # Move cursor to row 5, col 10
saved_y, saved_x = screen.cursor.y, screen.cursor.x
stream.feed(b"\x1b[3S")
assert screen.cursor.y == saved_y
assert screen.cursor.x == saved_x
-175
View File
@@ -1,175 +0,0 @@
"""Tests for CLI module."""
from click.testing import CliRunner
from webterm import cli
def _close_coroutine(coro) -> None:
coro.close()
class TestCLI:
"""Tests for CLI command."""
def test_cli_help(self):
"""Test CLI help output."""
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert result.exit_code == 0
assert "terminal" in result.output.lower() or "command" in result.output.lower()
def test_cli_runs_terminal_command(self, 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", _close_coroutine)
runner = CliRunner()
result = runner.invoke(cli.app, ["htop"])
assert result.exit_code == 0
assert calls["terminal"][1] == "htop"
def test_cli_runs_default_shell(self, monkeypatch):
import os
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.setenv("SHELL", "/bin/zsh")
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.asyncio, "run", _close_coroutine)
runner = CliRunner()
result = runner.invoke(cli.app, [])
assert result.exit_code == 0
assert calls["terminal"][1] == os.environ["SHELL"]
def test_cli_version(self):
"""Test CLI version output."""
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--version"])
assert result.exit_code == 0
assert "version" in result.output
def test_cli_port_option(self):
"""Test CLI port option parsing."""
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--port" in result.output or "-p" in result.output
def test_cli_host_option(self):
"""Test CLI host option parsing."""
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--host" in result.output or "-H" in result.output
class TestCLIOptions:
"""Tests for CLI option handling."""
def test_debug_option(self):
"""Test --debug option exists."""
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--docker-watch" in result.output
def test_no_run_option(self):
"""Test --no-run option exists."""
cli_app = cli.app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
# Check that basic options are documented
assert "port" in result.output.lower()
def test_package_version_fallback(monkeypatch):
def raise_missing(_name: str):
raise cli.PackageNotFoundError("webterm")
monkeypatch.setattr(cli, "version", raise_missing)
assert cli._package_version() == "0.0.0"
def test_cli_docker_watch_mode(monkeypatch):
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_terminal(self, name, command, slug):
calls["terminal"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
def run_and_close(coro):
calls.setdefault("run", True)
coro.close()
monkeypatch.setattr(cli.asyncio, "run", run_and_close)
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)
def run_and_close(coro):
calls.setdefault("run", True)
coro.close()
monkeypatch.setattr(cli.asyncio, "run", run_and_close)
runner = CliRunner()
result = runner.invoke(cli.app, ["--docker-watch"])
assert result.exit_code == 0
assert calls.get("run") is True
-71
View File
@@ -1,71 +0,0 @@
import asyncio
from pathlib import Path
from click.testing import CliRunner
from webterm import cli
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
manifest = tmp_path / "landing.yaml"
manifest.write_text(
"""
- name: One
slug: one
command: echo one
"""
)
called = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
called["init"] = True
def add_terminal(self, name, command, slug):
called["terminal"] = (name, command, slug)
async def run(self):
called["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli, "asyncio", asyncio)
runner = CliRunner()
result = runner.invoke(cli.app, ["-L", str(manifest)])
assert result.exit_code == 0
assert called.get("terminal") == ("One", "echo one", "one")
assert called.get("run") is True
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
manifest = tmp_path / "compose.yaml"
manifest.write_text(
"""
services:
svc1:
labels:
webterm-command: echo svc1
"""
)
called = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
called["init"] = True
def add_terminal(self, name, command, slug):
called["terminal"] = (name, command, slug)
async def run(self):
called["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli, "asyncio", asyncio)
runner = CliRunner()
result = runner.invoke(cli.app, ["-C", str(manifest)])
assert result.exit_code == 0
assert called.get("terminal") == ("svc1", "echo svc1", "svc1")
assert called.get("run") is True
-108
View File
@@ -1,108 +0,0 @@
"""Tests for configuration handling."""
from __future__ import annotations
import pytest
from webterm.config import App, Config
class TestApp:
"""Tests for App configuration."""
def test_create_terminal_app(self) -> None:
"""Test creating a terminal app configuration."""
app = App(
name="My Terminal",
slug="my-terminal",
terminal=True,
command="bash",
)
assert app.name == "My Terminal"
assert app.slug == "my-terminal"
assert app.terminal is True
assert app.command == "bash"
def test_create_terminal_app_defaults(self) -> None:
"""Test creating a terminal app configuration with defaults."""
app = App(
name="My App",
slug="my-app",
command="bash",
)
assert app.terminal is False
class TestConfig:
"""Tests for Config."""
def test_create_config_with_apps(self) -> None:
"""Test creating a config with apps."""
app = App(name="Test", slug="test", terminal=True, command="bash")
config = Config(apps=[app])
assert len(config.apps) == 1
assert config.apps[0].name == "Test"
def test_create_empty_config(self) -> None:
"""Test creating a config with no apps."""
config = Config(apps=[])
assert len(config.apps) == 0
class TestDefaultConfig:
"""Tests for default_config function."""
def test_default_config_returns_config(self):
"""Test that default_config returns a Config object."""
from webterm.config import default_config
config = default_config()
assert config is not None
assert hasattr(config, "apps")
class TestLoadConfig:
"""Tests for load_config function."""
def test_load_config_parses_terminal_only(self, tmp_path):
from webterm.config import load_config
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[terminal.shell]
command = "bash"
""".lstrip()
)
config = load_config(config_path)
assert len(config.apps) == 1
assert {a.name for a in config.apps} == {"shell"}
assert any(a.terminal for a in config.apps)
def test_load_config_rejects_app_entries(self, tmp_path):
from webterm.config import load_config
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[app."My App"]
command = "echo hi"
""".lstrip()
)
with pytest.raises(ValueError):
load_config(config_path)
def test_load_config_expands_vars(self, tmp_path, monkeypatch):
from webterm.config import load_config
monkeypatch.setenv("MY_CMD", "echo expanded")
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[terminal.t]
command = "$MY_CMD"
""".lstrip()
)
config = load_config(config_path)
assert config.apps[0].command == "echo expanded"
-47
View File
@@ -1,47 +0,0 @@
import tempfile
from pathlib import Path
from webterm.config import load_compose_manifest, load_landing_yaml
def test_load_landing_yaml_simple():
data = """
- name: One
slug: one
command: echo one
- name: Two
command: echo two
"""
with tempfile.NamedTemporaryFile("w+", delete=False) as f:
f.write(data)
f.flush()
apps = load_landing_yaml(Path(f.name))
assert len(apps) == 2
assert apps[0].slug == "one"
assert apps[1].command == "echo two"
def test_load_compose_manifest_reads_label():
data = """
services:
svc1:
labels:
webterm-command: echo svc1
webterm-theme: nord
svc2:
labels:
- webterm-command=echo svc2
svc3:
labels:
other: value
"""
with tempfile.NamedTemporaryFile("w+", delete=False) as f:
f.write(data)
f.flush()
apps = load_compose_manifest(Path(f.name))
slugs = {a.slug for a in apps}
commands = {a.command for a in apps}
assert slugs == {"svc1", "svc2"}
assert "echo svc1" in commands and "echo svc2" in commands
svc1 = next(app for app in apps if app.slug == "svc1")
assert svc1.theme == "nord"
-34
View File
@@ -1,34 +0,0 @@
"""Tests for constants helpers."""
from __future__ import annotations
def test_get_environ_bool(monkeypatch):
from webterm.constants import get_environ_bool
monkeypatch.setenv("FLAG", "1")
assert get_environ_bool("FLAG") is True
monkeypatch.setenv("FLAG", "0")
assert get_environ_bool("FLAG") is False
def test_get_environ_int_keyerror(monkeypatch):
from webterm.constants import get_environ_int
monkeypatch.delenv("INT", raising=False)
assert get_environ_int("INT", 7) == 7
def test_get_environ_int_valueerror(monkeypatch):
from webterm.constants import get_environ_int
monkeypatch.setenv("INT", "not-an-int")
assert get_environ_int("INT", 7) == 7
def test_get_environ_int_valid(monkeypatch):
from webterm.constants import get_environ_int
monkeypatch.setenv("INT", "42")
assert get_environ_int("INT", 7) == 42
-274
View File
@@ -1,274 +0,0 @@
"""Tests for docker_exec_session module."""
from __future__ import annotations
import asyncio
from collections import deque
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from webterm.docker_exec_session import (
REPLAY_BUFFER_SIZE,
DockerExecSession,
DockerExecSpec,
)
class FakeSocket:
"""Simple fake socket for unit tests."""
def __init__(self, recv_chunks: list[bytes] | None = None) -> None:
self._recv_chunks = deque(recv_chunks or [])
self.sent = b""
self.connected_path: str | None = None
self.timeout: float | None = None
self.closed = False
def settimeout(self, timeout: float | None) -> None:
self.timeout = timeout
def connect(self, path: str) -> None:
self.connected_path = path
def sendall(self, data: bytes) -> None:
self.sent += data
def recv(self, size: int) -> bytes:
if not self._recv_chunks:
return b""
chunk = self._recv_chunks.popleft()
if len(chunk) > size:
self._recv_chunks.appendleft(chunk[size:])
return chunk[:size]
return chunk
def close(self) -> None:
self.closed = True
class FakePoller:
"""Minimal fake poller for unit tests."""
def __init__(self, queue: asyncio.Queue[bytes | None]) -> None:
self.queue = queue
self.added: int | None = None
self.removed: int | None = None
def add_file(self, file_descriptor: int) -> asyncio.Queue[bytes | None]:
self.added = file_descriptor
return self.queue
def remove_file(self, file_descriptor: int) -> None:
self.removed = file_descriptor
def build_response(status: int, body: bytes, headers: dict[str, str] | None = None) -> bytes:
header_map = {"Content-Length": str(len(body))}
if headers:
header_map.update(headers)
lines = [f"HTTP/1.1 {status} Status"] + [f"{k}: {v}" for k, v in header_map.items()]
return ("\r\n".join(lines) + "\r\n\r\n").encode("utf-8") + body
@pytest.fixture
def docker_exec_session(mock_poller) -> DockerExecSession:
return DockerExecSession(
mock_poller,
"sid",
DockerExecSpec(container="container", command=["/bin/sh"]),
socket_path="/tmp/docker.sock",
)
def test_read_http_response_reads_full_body(docker_exec_session):
header = b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nX-Test: value\r\n\r\nhello "
fake_socket = FakeSocket([header, b"world"])
status, headers, body = docker_exec_session._read_http_response(fake_socket)
assert status == 200
assert headers["content-length"] == "11"
assert headers["x-test"] == "value"
assert body == b"hello world"
def test_read_http_response_incomplete_headers_returns_empty(docker_exec_session):
fake_socket = FakeSocket([b"HTTP/1.1 200 OK\r\n"])
status, headers, body = docker_exec_session._read_http_response(fake_socket)
assert status == 0
assert headers == {}
assert body == b""
def test_request_json_success_sends_payload(docker_exec_session, monkeypatch):
response = build_response(200, b'{"ok": true}')
fake_socket = FakeSocket([response])
def socket_factory(*_args, **_kwargs):
return fake_socket
monkeypatch.setattr("socket.socket", socket_factory)
result = docker_exec_session._request_json("POST", "/test", {"alpha": "beta"})
assert result == {"ok": True}
assert fake_socket.connected_path == "/tmp/docker.sock"
request = fake_socket.sent.decode("utf-8")
assert "POST /test HTTP/1.1" in request
assert "Content-Type: application/json" in request
payload = '{"alpha": "beta"}'
assert f"Content-Length: {len(payload)}" in request
assert request.endswith(payload)
assert fake_socket.closed is True
def test_request_json_error_raises(docker_exec_session, monkeypatch):
response = build_response(404, b"nope")
fake_socket = FakeSocket([response])
def socket_factory(*_args, **_kwargs):
return fake_socket
monkeypatch.setattr("socket.socket", socket_factory)
with pytest.raises(RuntimeError, match=r"Docker API request failed \(404\)"):
docker_exec_session._request_json("GET", "/missing", None)
assert fake_socket.closed is True
def test_start_exec_socket_error_status_closes_socket(docker_exec_session, monkeypatch):
response = build_response(500, b"boom")
fake_socket = FakeSocket([response])
def socket_factory(*_args, **_kwargs):
return fake_socket
monkeypatch.setattr("socket.socket", socket_factory)
with pytest.raises(RuntimeError, match=r"Docker API exec start failed"):
docker_exec_session._start_exec_socket("exec-id")
assert fake_socket.closed is True
def test_resize_exec_calls_request_json(docker_exec_session):
docker_exec_session._exec_id = "exec-id"
docker_exec_session._request_json = MagicMock() # type: ignore[method-assign]
docker_exec_session._resize_exec(100, 40)
docker_exec_session._request_json.assert_called_once_with(
"POST",
"/exec/exec-id/resize?h=40&w=100",
None,
)
@pytest.mark.asyncio
async def test_update_screen_increments_change_counter(docker_exec_session):
initial_counter = docker_exec_session._change_counter
await docker_exec_session._update_screen(b"Hello\r\n")
assert docker_exec_session._change_counter > initial_counter
@pytest.mark.asyncio
async def test_update_screen_logs_on_exception(docker_exec_session):
with (
patch.object(docker_exec_session._stream, "feed", side_effect=RuntimeError("boom")),
patch("webterm.docker_exec_session.log.warning") as warn,
):
await docker_exec_session._update_screen(b"\xff")
assert warn.called
@pytest.mark.asyncio
async def test_update_screen_preserves_utf8_bytes_with_c1_values(docker_exec_session):
await docker_exec_session._update_screen("✓ ok\r\n".encode())
lines = await docker_exec_session.get_screen_lines()
assert "✓ ok" in lines[0]
@pytest.mark.asyncio
async def test_add_to_replay_buffer_trims_old_data(docker_exec_session):
first_chunk = b"a" * (REPLAY_BUFFER_SIZE - 1)
second_chunk = b"b" * 10
await docker_exec_session._add_to_replay_buffer(first_chunk)
await docker_exec_session._add_to_replay_buffer(second_chunk)
assert docker_exec_session._replay_buffer_size == len(second_chunk)
assert await docker_exec_session.get_replay_buffer() == second_chunk
@pytest.mark.asyncio
async def test_run_filters_da_responses():
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
await queue.put(b"hello\x1b[?1;10;0cworld")
await queue.put(b"done")
await queue.put(None)
poller = FakePoller(queue)
session = DockerExecSession(
poller,
"sid",
DockerExecSpec(container="container", command=["/bin/sh"]),
socket_path="/tmp/docker.sock",
)
session.master_fd = 10
fake_socket = FakeSocket([])
session._sock = fake_socket
connector = MagicMock()
connector.on_data = AsyncMock()
connector.on_close = AsyncMock()
session._connector = connector
with (
patch.object(session, "_add_to_replay_buffer", new=AsyncMock()) as add_buffer,
patch.object(session, "_update_screen", new=AsyncMock()) as update_screen,
):
await session.run()
connector.on_data.assert_has_awaits([call(b"helloworld"), call(b"done")])
add_buffer.assert_has_awaits([call(b"helloworld"), call(b"done")])
update_screen.assert_has_awaits([call(b"helloworld"), call(b"done")])
connector.on_close.assert_awaited_once()
assert poller.removed == 10
assert fake_socket.closed is True
assert session.master_fd is None
assert session._escape_buffer == b""
@pytest.mark.asyncio
async def test_run_handles_partial_da_sequences():
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
await queue.put(b"hi\x1b[?1")
await queue.put(b"0;0cbye")
await queue.put(None)
poller = FakePoller(queue)
session = DockerExecSession(
poller,
"sid",
DockerExecSpec(container="container", command=["/bin/sh"]),
socket_path="/tmp/docker.sock",
)
session.master_fd = 10
fake_socket = FakeSocket([])
session._sock = fake_socket
connector = MagicMock()
connector.on_data = AsyncMock()
connector.on_close = AsyncMock()
session._connector = connector
with (
patch.object(session, "_add_to_replay_buffer", new=AsyncMock()) as add_buffer,
patch.object(session, "_update_screen", new=AsyncMock()) as update_screen,
):
await session.run()
connector.on_data.assert_has_awaits([call(b"hi"), call(b"bye")])
add_buffer.assert_has_awaits([call(b"hi"), call(b"bye")])
update_screen.assert_has_awaits([call(b"hi"), call(b"bye")])
connector.on_close.assert_awaited_once()
assert poller.removed == 10
assert fake_socket.closed is True
assert session._escape_buffer == b""
-354
View File
@@ -1,354 +0,0 @@
"""Tests for docker_stats module."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from webterm.docker_stats import (
DEFAULT_DOCKER_SOCKET,
STATS_HISTORY_SIZE,
DockerStatsCollector,
get_docker_socket_path,
render_sparkline_svg,
)
class TestRenderSparklineSvg:
"""Tests for SVG sparkline rendering."""
def test_empty_values(self):
"""Empty values produce empty SVG."""
svg = render_sparkline_svg([])
assert "<svg" in svg
assert "width=" in svg
assert "polygon" not in svg # No data to draw
def test_single_value(self):
"""Single value renders correctly."""
svg = render_sparkline_svg([50.0])
assert "<svg" in svg
assert "polygon" in svg
assert "polyline" in svg
def test_multiple_values(self):
"""Multiple values render as sparkline."""
values = [10.0, 50.0, 30.0, 80.0, 20.0]
svg = render_sparkline_svg(values)
assert "<svg" in svg
assert "polygon" in svg
assert "polyline" in svg
def test_custom_dimensions(self):
"""Custom width/height are applied."""
svg = render_sparkline_svg([50.0], width=200, height=40)
assert 'width="200"' in svg
assert 'height="40"' in svg
def test_custom_colors(self):
"""Custom colors are applied."""
svg = render_sparkline_svg(
[50.0],
stroke_color="#ff0000",
fill_color="rgba(255, 0, 0, 0.3)",
)
assert "#ff0000" in svg
assert "rgba(255, 0, 0, 0.3)" in svg
def test_zero_values(self):
"""All zero values don't cause division errors."""
svg = render_sparkline_svg([0.0, 0.0, 0.0])
assert "<svg" in svg
def test_high_values(self):
"""High CPU values (100%+) render correctly."""
svg = render_sparkline_svg([100.0, 150.0, 200.0])
assert "<svg" in svg
class TestDockerStatsCollector:
"""Tests for Docker stats collector."""
@pytest.fixture
def cpu_stats_pair(self):
return (
{
"cpu_usage": {"total_usage": 1000000000},
"system_cpu_usage": 10000000000,
"online_cpus": 4,
},
{
"cpu_usage": {"total_usage": 500000000},
"system_cpu_usage": 5000000000,
},
)
def test_available_checks_socket(self, tmp_path):
"""available property checks socket existence and connectivity."""
socket_path = tmp_path / "docker.sock"
collector = DockerStatsCollector(str(socket_path))
assert collector.available is False
# Just touching the file isn't enough - need actual socket connectivity
# Since we can't easily create a real Unix socket in tests,
# verify that a non-socket file returns False
socket_path.touch()
assert collector.available is False # File exists but can't connect
def test_get_docker_socket_path_env(self, monkeypatch):
monkeypatch.setenv("DOCKER_HOST", "unix:///tmp/custom.sock")
assert get_docker_socket_path() == "/tmp/custom.sock"
monkeypatch.setenv("DOCKER_HOST", "/tmp/alt.sock")
assert get_docker_socket_path() == "/tmp/alt.sock"
monkeypatch.setenv("DOCKER_HOST", "tcp://127.0.0.1:2375")
assert get_docker_socket_path() == DEFAULT_DOCKER_SOCKET
def test_get_cpu_history_empty(self):
"""Empty history returns empty list."""
collector = DockerStatsCollector("/nonexistent")
assert collector.get_cpu_history("container1") == []
def test_get_cpu_history_with_data(self):
"""CPU history returns stored values."""
collector = DockerStatsCollector("/nonexistent")
collector._cpu_history["test"] = [10.0, 20.0, 30.0]
# get_cpu_history converts deque to list
collector._cpu_history["test"] = list.__new__(list)
collector._cpu_history["test"].extend([10.0, 20.0, 30.0])
history = collector.get_cpu_history("test")
assert history == [10.0, 20.0, 30.0]
def test_calculate_cpu_percent(self, cpu_stats_pair):
"""CPU percentage calculation."""
collector = DockerStatsCollector("/nonexistent")
cpu_stats, precpu_stats = cpu_stats_pair
result = collector._calculate_cpu_percent("test", cpu_stats, precpu_stats)
assert result is not None
assert 0 <= result <= 400 # 4 CPUs max
def test_calculate_cpu_percent_zero_delta(self):
"""Zero system delta returns None."""
collector = DockerStatsCollector("/nonexistent")
cpu_stats = {
"cpu_usage": {"total_usage": 1000},
"system_cpu_usage": 1000,
"online_cpus": 1,
}
precpu_stats = {
"cpu_usage": {"total_usage": 1000},
"system_cpu_usage": 1000,
}
result = collector._calculate_cpu_percent("test", cpu_stats, precpu_stats)
assert result is None
def test_calculate_cpu_percent_uses_previous_stats(self):
collector = DockerStatsCollector("/nonexistent")
collector._prev_cpu["svc"] = (1000, 2000)
cpu_stats = {
"cpu_usage": {"total_usage": 2000},
"system_cpu_usage": 4000,
"online_cpus": 2,
}
precpu_stats = {
"cpu_usage": {"total_usage": 0},
"system_cpu_usage": 0,
}
result = collector._calculate_cpu_percent("svc", cpu_stats, precpu_stats)
assert result == 100.0
def test_start_without_socket(self, tmp_path):
"""Start does nothing if socket not available."""
collector = DockerStatsCollector(str(tmp_path / "nonexistent.sock"))
collector.start(["container1"])
assert collector._running is False
assert collector._task is None
@pytest.mark.asyncio
async def test_stop_without_start(self):
"""Stop is safe to call without start."""
collector = DockerStatsCollector("/nonexistent")
await collector.stop() # Should not raise
@pytest.mark.asyncio
async def test_make_request_no_socket(self):
"""Request returns None if socket unavailable."""
collector = DockerStatsCollector("/nonexistent")
result = await collector._make_request("/test")
assert result is None
def test_parse_docker_response_parses_json(self):
collector = DockerStatsCollector("/nonexistent")
response = b'HTTP/1.0 200 OK\r\n\r\n{"ok": true}'
assert collector._parse_docker_response("/stats", response) == {"ok": True}
def test_parse_docker_response_filters_non_200(self):
collector = DockerStatsCollector("/nonexistent")
response = b'HTTP/1.0 404 Not Found\r\n\r\n{"ok": true}'
assert collector._parse_docker_response("/stats", response) is None
def test_parse_docker_response_finds_json_in_body(self):
collector = DockerStatsCollector("/nonexistent")
response = b'HTTP/1.0 200 OK\r\n\r\njunk\r\n{"ok": true}\r\n'
assert collector._parse_docker_response("/stats", response) == {"ok": True}
def test_parse_docker_response_invalid_json(self):
collector = DockerStatsCollector("/nonexistent")
response = b"HTTP/1.0 200 OK\r\n\r\n{bad json"
assert collector._parse_docker_response("/stats", response) is None
@pytest.mark.asyncio
async def test_discover_containers_maps_compose_services(self):
collector = DockerStatsCollector("/nonexistent", compose_project="demo")
collector._make_request = AsyncMock( # type: ignore[method-assign]
return_value=[
{
"Id": "abcdef1234567890",
"Names": ["/demo_web_1"],
"Labels": {
"com.docker.compose.project": "demo",
"com.docker.compose.service": "web",
},
}
]
)
mapping = await collector._discover_containers(["web"])
assert mapping == {"web": "abcdef123456"}
@pytest.mark.asyncio
async def test_discover_containers_falls_back_to_name(self):
collector = DockerStatsCollector("/nonexistent")
collector._make_request = AsyncMock( # type: ignore[method-assign]
return_value=[
{
"Id": "1234567890abcdef",
"Names": ["/api"],
"Labels": {},
}
]
)
mapping = await collector._discover_containers(["api"])
assert mapping == {"api": "1234567890ab"}
def test_cpu_history_max_size(self):
"""CPU history respects max size."""
from collections import deque
collector = DockerStatsCollector("/nonexistent")
collector._cpu_history["test"] = deque(maxlen=STATS_HISTORY_SIZE)
# Add more than max entries
for i in range(STATS_HISTORY_SIZE + 10):
collector._cpu_history["test"].append(float(i))
assert len(collector._cpu_history["test"]) == STATS_HISTORY_SIZE
@pytest.mark.asyncio
async def test_poll_container_appends_history(self):
collector = DockerStatsCollector("/nonexistent")
collector._make_request = AsyncMock( # type: ignore[method-assign]
return_value={
"cpu_stats": {"system_cpu_usage": 4000, "cpu_usage": {"total_usage": 2000}},
"precpu_stats": {
"system_cpu_usage": 2000,
"cpu_usage": {"total_usage": 1000},
},
}
)
with patch.object(
collector,
"_calculate_cpu_percent",
return_value=12.5,
):
await collector._poll_container("svc", "container")
assert collector.get_cpu_history("svc") == [12.5]
def test_add_service_dynamic(self):
"""Services can be added dynamically after start."""
collector = DockerStatsCollector("/nonexistent")
collector._service_names = ["svc1"]
collector.add_service("svc2")
assert "svc2" in collector._service_names
# Adding same service again is a no-op
collector.add_service("svc2")
assert collector._service_names.count("svc2") == 1
def test_remove_service_dynamic(self):
"""Services can be removed dynamically."""
from collections import deque
collector = DockerStatsCollector("/nonexistent")
collector._service_names = ["svc1", "svc2"]
collector._cpu_history["svc1"] = deque([10.0, 20.0])
collector._prev_cpu["svc1"] = (100, 200)
collector.remove_service("svc1")
assert "svc1" not in collector._service_names
assert "svc1" not in collector._cpu_history
assert "svc1" not in collector._prev_cpu
# Removing non-existent service is safe
collector.remove_service("nonexistent") # Should not raise
class TestLocalServerSparklineEndpoint:
"""Tests for the CPU sparkline endpoint in LocalServer."""
@pytest.mark.asyncio
async def test_sparkline_endpoint_missing_container(self):
"""Missing container param returns 400."""
from aiohttp.web import HTTPBadRequest
from webterm.config import Config
from webterm.local_server import LocalServer
server = LocalServer("./", Config(), compose_mode=True)
request = MagicMock()
request.query = {}
with pytest.raises(HTTPBadRequest):
await server._handle_cpu_sparkline(request)
@pytest.mark.asyncio
async def test_sparkline_endpoint_returns_svg(self):
"""Sparkline endpoint returns SVG."""
from webterm.config import Config
from webterm.local_server import LocalServer
server = LocalServer("./", Config(), compose_mode=True)
request = MagicMock()
request.query = {"container": "test", "width": "80", "height": "20"}
response = await server._handle_cpu_sparkline(request)
assert response.content_type == "image/svg+xml"
assert "<svg" in response.text
@pytest.mark.asyncio
async def test_sparkline_with_stats_collector(self):
"""Sparkline uses stats collector data when available."""
from webterm.config import Config
from webterm.local_server import LocalServer
server = LocalServer("./", Config(), compose_mode=True)
server._docker_stats = MagicMock()
server._docker_stats.get_cpu_history.return_value = [10.0, 20.0, 30.0]
request = MagicMock()
request.query = {"container": "test"}
response = await server._handle_cpu_sparkline(request)
server._docker_stats.get_cpu_history.assert_called_once_with("test")
assert "<svg" in response.text
-443
View File
@@ -1,443 +0,0 @@
"""Tests for docker_watcher module."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from webterm.docker_watcher import (
AUTO_COMMAND_SENTINEL,
LABEL_NAME,
THEME_LABEL,
DockerWatcher,
_has_webterm_label,
)
@pytest.fixture
def session_manager():
manager = MagicMock()
manager.apps_by_slug = {}
manager.apps = []
manager.get_session_by_route_key.return_value = None
return manager
@pytest.fixture
def docker_watcher(session_manager):
return DockerWatcher(session_manager)
class TestDockerWatcher:
"""Tests for DockerWatcher class."""
def test_container_to_slug(self, docker_watcher):
"""Test slug generation from container names."""
# Test basic name
container = {"Names": ["/my-container"]}
assert docker_watcher._container_to_slug(container) == "my-container"
# Test with underscores
container = {"Names": ["/my_container_name"]}
assert docker_watcher._container_to_slug(container) == "my-container-name"
# Test with dots
container = {"Names": ["/service.name"]}
assert docker_watcher._container_to_slug(container) == "service-name"
# Test fallback to ID
container = {"Id": "abc123def456"}
assert docker_watcher._container_to_slug(container) == "abc123def456"
def test_get_container_name(self, docker_watcher):
"""Test extracting container name."""
container = {"Names": ["/my-container"]}
assert docker_watcher._get_container_name(container) == "my-container"
container = {"Names": []}
container["Id"] = "abc123def456789"
assert docker_watcher._get_container_name(container) == "abc123def456"
def test_get_container_command_auto(self, docker_watcher):
"""Test command generation when label is 'auto'."""
container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}}
assert docker_watcher._get_container_command(container) == AUTO_COMMAND_SENTINEL
def test_get_container_command_custom(self, docker_watcher):
"""Test command when label has custom value."""
container = {
"Names": ["/my-container"],
"Labels": {LABEL_NAME: "docker logs -f my-container"},
}
assert docker_watcher._get_container_command(container) == "docker logs -f my-container"
def test_get_container_theme(self, docker_watcher):
container = {"Labels": {THEME_LABEL: "nord"}}
assert docker_watcher._get_container_theme(container) == "nord"
def test_get_container_theme_blank(self, docker_watcher):
container = {"Labels": {THEME_LABEL: " "}}
assert docker_watcher._get_container_theme(container) is None
@pytest.mark.asyncio
async def test_add_container(self, session_manager):
"""Test adding a container."""
on_added = MagicMock()
watcher = DockerWatcher(session_manager, on_container_added=on_added)
container = {
"Id": "abc123",
"Names": ["/test-container"],
"Labels": {LABEL_NAME: "auto", THEME_LABEL: "monokai"},
}
await watcher._add_container(container)
# Should add to session manager
session_manager.add_app.assert_called_once()
call_args = session_manager.add_app.call_args
assert call_args[0][0] == "test-container" # name
assert call_args[0][1] == AUTO_COMMAND_SENTINEL # command
assert call_args[0][2] == "test-container" # slug
assert call_args[1]["terminal"] is True
assert call_args[1]["theme"] == "monokai"
# Should call callback
on_added.assert_called_once_with("test-container", "test-container", call_args[0][1])
# Should track container
assert "test-container" in watcher._managed_containers
@pytest.mark.asyncio
async def test_add_container_already_managed(self, session_manager):
"""Test adding a container that's already managed."""
watcher = DockerWatcher(session_manager)
watcher._managed_containers["test-container"] = "abc123"
container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}}
await watcher._add_container(container)
# Should not add again
session_manager.add_app.assert_not_called()
@pytest.mark.asyncio
async def test_remove_container(self, session_manager):
"""Test removing a container."""
session_manager.apps_by_slug = {"test-container": MagicMock()}
session_manager.apps = [session_manager.apps_by_slug["test-container"]]
session_manager.get_session_by_route_key.return_value = None
on_removed = MagicMock()
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
watcher._managed_containers["test-container"] = "abc123"
await watcher._remove_container("abc123")
# Should remove from tracking
assert "test-container" not in watcher._managed_containers
# Should call callback
on_removed.assert_called_once_with("test-container")
@pytest.mark.asyncio
async def test_remove_container_with_active_session(self, session_manager):
"""Test removing a container that has an active session cleans up session."""
mock_session = MagicMock()
mock_app = MagicMock()
session_manager.apps_by_slug = {"test-container": mock_app}
session_manager.apps = [mock_app]
session_manager.get_session_by_route_key.return_value = mock_session
session_manager.routes = MagicMock()
session_manager.routes.get.return_value = "session-123"
session_manager.close_session = AsyncMock()
on_removed = MagicMock()
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
watcher._managed_containers["test-container"] = "abc123"
await watcher._remove_container("abc123")
# Session should be closed
session_manager.close_session.assert_called_once_with("session-123")
# App should be removed after session cleanup
assert "test-container" not in session_manager.apps_by_slug
assert mock_app not in session_manager.apps
# Container should be untracked
assert "test-container" not in watcher._managed_containers
on_removed.assert_called_once_with("test-container")
"""Test removing a container that's not managed."""
on_removed = MagicMock()
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
await watcher._remove_container("unknown123")
# Should not call callback
on_removed.assert_not_called()
@pytest.mark.asyncio
async def test_start_stop(self, session_manager):
"""Test starting and stopping the watcher."""
watcher = DockerWatcher(session_manager, socket_path="/nonexistent.sock")
# Mock the methods that would fail without Docker
watcher._get_labeled_containers = AsyncMock(return_value=[])
watcher._watch_events = AsyncMock()
await watcher.start()
assert watcher._running is True
await watcher.stop()
assert watcher._running is False
class TestDockerWatcherIntegration:
"""Integration-style tests for Docker watcher."""
@pytest.mark.asyncio
async def test_handle_start_event(self, session_manager):
"""Test handling a container start event."""
watcher = DockerWatcher(session_manager)
# Mock the docker request to return container info
async def mock_request(method, path):
if "/containers/" in path and "/json" in path:
return (
200,
'{"Name": "/test-service", "Config": {"Labels": {"webterm-command": "auto"}}}',
)
return 404, ""
watcher._docker_request = mock_request
event = {
"Action": "start",
"Actor": {"ID": "container123", "Attributes": {LABEL_NAME: "auto"}},
}
await watcher._handle_event(event)
# Should add container
session_manager.add_app.assert_called_once()
@pytest.mark.asyncio
async def test_handle_die_event(self, session_manager):
"""Test handling a container die event."""
session_manager.apps_by_slug = {}
session_manager.apps = []
session_manager.get_session_by_route_key.return_value = None
watcher = DockerWatcher(session_manager)
watcher._managed_containers["test-service"] = "container123"
event = {
"Action": "die",
"Actor": {"ID": "container123", "Attributes": {LABEL_NAME: "auto"}},
}
await watcher._handle_event(event)
# Should remove container
assert "test-service" not in watcher._managed_containers
@pytest.mark.asyncio
async def test_handle_event_without_label(self, session_manager):
"""Test that events without our label are ignored."""
watcher = DockerWatcher(session_manager)
watcher._docker_request = AsyncMock(return_value=(404, ""))
event = {
"Action": "start",
"Actor": {
"ID": "container123",
"Attributes": {}, # No label
},
}
await watcher._handle_event(event)
# Should not add container
session_manager.add_app.assert_not_called()
@pytest.mark.asyncio
async def test_handle_start_event_label_added_after_start(self, session_manager):
"""Container that gains label after start is picked up."""
watcher = DockerWatcher(session_manager)
async def mock_request(method, path):
if "/containers/" in path and "/json" in path:
return (
200,
'{"Name": "/test-service", "Config": {"Labels": {"webterm-command": "auto"}}}',
)
return 404, ""
watcher._docker_request = mock_request
event = {
"Action": "start",
"Actor": {
"ID": "container123",
"Attributes": {}, # Labels not present on event
},
}
await watcher._handle_event(event)
session_manager.add_app.assert_called_once()
@pytest.mark.asyncio
async def test_handle_start_event_null_labels(self, session_manager):
"""Container with null labels in Docker inspect response is handled gracefully."""
watcher = DockerWatcher(session_manager)
async def mock_request(method, path):
if "/containers/" in path and "/json" in path:
# Docker returns null for Labels when container has none
return (
200,
'{"Name": "/no-labels-container", "Config": {"Labels": null}}',
)
return 404, ""
watcher._docker_request = mock_request
event = {
"Action": "start",
"Actor": {
"ID": "container456",
"Attributes": {},
},
}
# Should not raise an exception
await watcher._handle_event(event)
# Should not add container (no webterm labels)
session_manager.add_app.assert_not_called()
@pytest.mark.parametrize(
("labels", "expected"),
[
({"webterm-command": "echo hi"}, "echo hi"),
({"webterm-command": "auto"}, AUTO_COMMAND_SENTINEL),
({"webterm-command": ""}, AUTO_COMMAND_SENTINEL),
({"other": "value"}, AUTO_COMMAND_SENTINEL),
],
)
def test_get_container_command_variants(docker_watcher, labels, expected):
container = {"Names": ["/my-container"], "Labels": labels}
assert docker_watcher._get_container_command(container) == expected
def test_auto_command_env_override(monkeypatch, docker_watcher):
monkeypatch.setenv("WEBTERM_DOCKER_AUTO_COMMAND", "/bin/sh")
container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}}
assert docker_watcher._get_container_command(container) == AUTO_COMMAND_SENTINEL
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "body", "expected"),
[
(200, '[{"Id":"abc","Names":["/c1"],"Labels":{"webterm-command":"auto"}}]', 1),
(200, "[]", 0),
(500, "error", 0),
],
)
async def test_get_labeled_containers_handles_status(
docker_watcher, status, body, expected, monkeypatch
):
async def fake_request(method: str, path: str):
return status, body
monkeypatch.setattr(docker_watcher, "_docker_request", fake_request)
result = await docker_watcher._get_labeled_containers()
assert len(result) == expected
@pytest.mark.asyncio
async def test_watch_events_recovers_from_errors(docker_watcher, monkeypatch):
docker_watcher._running = True
async def fail_once(*_args, **_kwargs):
docker_watcher._running = False
raise OSError("boom")
async def fake_sleep(_seconds):
return None
monkeypatch.setattr("webterm.docker_watcher.asyncio.open_unix_connection", fail_once)
monkeypatch.setattr("webterm.docker_watcher.asyncio.sleep", fake_sleep)
await docker_watcher._watch_events()
class TestHasWebtermLabel:
"""Tests for _has_webterm_label helper."""
def test_has_command_label(self):
"""Container with webterm-command label is detected."""
assert _has_webterm_label({LABEL_NAME: "auto"}) is True
def test_has_theme_label(self):
"""Container with webterm-theme label is detected."""
assert _has_webterm_label({THEME_LABEL: "dracula"}) is True
def test_has_both_labels(self):
"""Container with both labels is detected."""
assert _has_webterm_label({LABEL_NAME: "bash", THEME_LABEL: "dark"}) is True
def test_no_webterm_labels(self):
"""Container without webterm labels is not detected."""
assert _has_webterm_label({"other-label": "value"}) is False
def test_empty_attributes(self):
"""Empty attributes means no labels."""
assert _has_webterm_label({}) is False
class TestHandleEventWithThemeLabel:
"""Tests for event handling with theme-only labels."""
@pytest.mark.asyncio
async def test_handle_start_event_theme_only(self):
"""Container with only theme label is picked up."""
manager = MagicMock()
manager.apps_by_slug = {}
manager.apps = []
watcher = DockerWatcher(manager)
async def mock_request(method, path):
if "/containers/" in path and "/json" in path:
import json
return 200, json.dumps(
{
"Id": "abc123",
"Name": "/themed-container",
"Config": {"Labels": {THEME_LABEL: "monokai"}},
}
)
return 404, ""
watcher._docker_request = mock_request
event = {
"Action": "start",
"Actor": {
"ID": "abc123",
"Attributes": {THEME_LABEL: "monokai"}, # Only theme label
},
}
await watcher._handle_event(event)
# Should add container with auto command
manager.add_app.assert_called_once()
call_args = manager.add_app.call_args
assert call_args[0][1] == AUTO_COMMAND_SENTINEL # command arg
assert call_args[1]["theme"] == "monokai"
-65
View File
@@ -1,65 +0,0 @@
import asyncio
import pytest
@pytest.mark.asyncio
async def test_exit_poller_noop_when_idle_wait_zero(monkeypatch):
from webterm import exit_poller
from webterm.exit_poller import ExitPoller
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
class FakeServer:
def __init__(self):
class SM:
def __init__(self):
self.sessions = {}
self.session_manager = SM()
self.exited = False
def force_exit(self):
self.exited = True
server = FakeServer()
poller = ExitPoller(server, idle_wait=0)
poller.start()
await asyncio.sleep(0.05)
poller.stop()
assert server.exited is False
@pytest.mark.asyncio
async def test_exit_poller_resets_idle_timer_when_session_appears(monkeypatch):
from webterm import exit_poller
from webterm.exit_poller import ExitPoller
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
class FakeServer:
def __init__(self):
class SM:
def __init__(self):
self.sessions = {}
self.session_manager = SM()
self.exited = False
def force_exit(self):
self.exited = True
server = FakeServer()
poller = ExitPoller(server, idle_wait=0.05)
poller.start()
# Let it become idle briefly, then add a session to reset.
await asyncio.sleep(0.02)
server.session_manager.sessions["x"] = object()
await asyncio.sleep(0.02)
server.session_manager.sessions.clear()
# Now ensure it can still exit after being idle long enough.
await asyncio.sleep(0.1)
poller.stop()
assert server.exited is True
-81
View File
@@ -1,81 +0,0 @@
"""Tests for LocalServer."""
from __future__ import annotations
from webterm.config import App, Config
from webterm.local_server import WEBTERM_STATIC_PATH, LocalServer
class TestLocalServer:
"""Tests for LocalServer."""
def test_static_path_exists(self) -> None:
"""Test that static path exists."""
assert WEBTERM_STATIC_PATH is not None
assert WEBTERM_STATIC_PATH.exists()
def test_static_path_has_required_files(self) -> None:
"""Test that static path contains required assets."""
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js" / "terminal.js").exists()
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
def test_create_server(self, tmp_path) -> None:
"""Test creating a LocalServer instance."""
app = App(name="Test", slug="test", terminal=True, command="echo test")
config = Config(apps=[app])
server = LocalServer(
str(tmp_path),
config,
host="127.0.0.1",
port=8080,
)
assert server.host == "127.0.0.1"
assert server.port == 8080
assert server.app_count == 1
def test_add_app(self, tmp_path) -> None:
"""Test adding an app to the server."""
config = Config(apps=[])
server = LocalServer(str(tmp_path), config, host="127.0.0.1", port=8080)
assert server.app_count == 0
server.add_app("New App", "echo hello", slug="new-app")
assert server.app_count == 1
class TestWebSocketProtocol:
"""Tests for WebSocket protocol handling."""
def test_stdin_message_format(self) -> None:
"""Test that stdin messages use correct format."""
import json
msg = json.dumps(["stdin", "hello"])
parsed = json.loads(msg)
assert parsed[0] == "stdin"
assert parsed[1] == "hello"
def test_resize_message_format(self) -> None:
"""Test that resize messages use correct format."""
import json
msg = json.dumps(["resize", {"width": 80, "height": 24}])
parsed = json.loads(msg)
assert parsed[0] == "resize"
assert parsed[1]["width"] == 80
assert parsed[1]["height"] == 24
def test_ping_pong_format(self) -> None:
"""Test ping/pong message format."""
import json
ping = json.dumps(["ping", "12345"])
parsed = json.loads(ping)
assert parsed[0] == "ping"
pong = json.dumps(["pong", "12345"])
parsed = json.loads(pong)
assert parsed[0] == "pong"
-812
View File
@@ -1,812 +0,0 @@
"""Tests for local_server module - unit tests for helper functions."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp import web
from webterm.config import App, Config
from webterm.local_server import (
LocalServer,
)
async def wait_for_asyncmock_call(mock: AsyncMock, timeout: float = 0.1) -> None:
async def _wait() -> None:
while mock.await_count == 0:
await asyncio.sleep(0)
await asyncio.wait_for(_wait(), timeout=timeout)
class TestGetStaticPath:
"""Tests for static path."""
def test_static_path_exists(self):
"""Test that static path exists."""
from webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists()
def test_static_path_has_js(self):
"""Test that static path has JS directory."""
from webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js").exists()
def test_static_path_has_wasm(self):
"""Test that static path has WASM file."""
from webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
class TestLocalServer:
"""Tests for LocalServer class."""
@pytest.fixture
def config(self):
"""Create a test config."""
return Config(
apps=[
App(name="Test", slug="test", path="./", command="echo test", terminal=True),
],
)
@pytest.fixture
def server(self, config, tmp_path):
"""Create a test server."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
return LocalServer(
config_path=str(config_file),
config=config,
host="localhost",
port=8080,
)
def test_init(self, server):
"""Test LocalServer initialization."""
assert server.host == "localhost"
assert server.port == 8080
assert server.session_manager is not None
def test_add_app(self, server):
"""Test adding a terminal app."""
server.add_app("New Terminal", "bash", "newapp")
assert "newapp" in server.session_manager.apps_by_slug
def test_add_terminal(self, server):
"""Test adding a terminal."""
server.add_terminal("Terminal", "bash", "term")
assert "term" in server.session_manager.apps_by_slug
app = server.session_manager.apps_by_slug["term"]
assert app.terminal is True
@pytest.mark.asyncio
async def test_create_terminal_session_uses_slug_and_starts_session(self, server, monkeypatch):
from webterm import local_server
monkeypatch.setattr(local_server, "generate", lambda: "fixed-session")
session = MagicMock()
session.get_screen_has_changes = AsyncMock(return_value=False)
session.start = AsyncMock()
monkeypatch.setattr(server.session_manager, "new_session", AsyncMock(return_value=session))
await server._create_terminal_session("test", 80, 24)
server.session_manager.new_session.assert_awaited_once_with(
"test",
"fixed-session",
"test",
size=(80, 24),
)
session.start.assert_awaited_once()
connector = session.start.call_args.args[0]
assert connector.session_id == "fixed-session"
assert connector.route_key == "test"
class TestLocalServerHelpers:
"""Tests for LocalServer helper methods."""
@pytest.mark.asyncio
async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch):
ws1 = MagicMock()
ws1.close = AsyncMock()
ws2 = MagicMock()
ws2.close = AsyncMock()
server._websocket_connections["a"] = ws1
server._websocket_connections["b"] = ws2
monkeypatch.setattr(server.session_manager, "close_all", AsyncMock())
server.on_keyboard_interrupt()
assert server._shutdown_task is not None
await server._shutdown_task
ws1.close.assert_awaited_once()
ws2.close.assert_awaited_once()
server.session_manager.close_all.assert_awaited_once()
assert server.exit_event.is_set()
@pytest.mark.asyncio
async def test_ws_resize_creates_session_when_slug_exists(self, server, monkeypatch):
server.session_manager.apps_by_slug["slug"] = App(
name="Known",
slug="slug",
path="./",
command="echo ok",
terminal=True,
)
monkeypatch.setattr(server, "_create_terminal_session", AsyncMock())
ws = MagicMock()
session_created = await server._dispatch_ws_message(
["resize", {"width": 100, "height": 40}],
"slug",
ws,
session_created=False,
)
assert session_created is True
server._create_terminal_session.assert_awaited_once_with("slug", 100, 40)
@pytest.mark.asyncio
async def test_ws_resize_sends_error_if_no_apps(self, server):
ws = MagicMock()
ws.send_json = AsyncMock()
server._websocket_connections["rk"] = ws
session_created = await server._dispatch_ws_message(
["resize", {"width": 80, "height": 24}],
"rk",
ws,
session_created=False,
)
assert session_created is True
ws.send_json.assert_awaited_once_with(["error", "No app configured"])
@pytest.mark.asyncio
async def test_create_terminal_session_sends_error_if_no_apps(self, server):
ws = MagicMock()
ws.send_json = AsyncMock()
server._websocket_connections["rk"] = ws
await server._create_terminal_session("rk", 80, 24)
ws.send_json.assert_awaited_once_with(["error", "No app configured"])
@pytest.mark.asyncio
async def test_screenshot_svg_handler_returns_svg(
self, server, monkeypatch, capsys, screen_buffer_factory, mock_session, mock_request
):
request = mock_request
request.query = {"route_key": "rk"}
screen_buffer = screen_buffer_factory(["hello", ""])
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
server.session_manager.apps_by_slug["rk"] = App(
name="Test",
slug="rk",
path="./",
command="echo test",
terminal=True,
theme="dracula",
)
monkeypatch.setattr(
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
)
response = await server._handle_screenshot(request)
assert response.content_type == "image/svg+xml"
assert "<svg" in response.text
assert "#282a36" in response.text
out = capsys.readouterr()
assert out.out == ""
assert out.err == ""
@pytest.mark.asyncio
async def test_screenshot_creates_session_for_known_slug(
self, server, monkeypatch, screen_buffer_factory, mock_session, mock_request
):
request = mock_request
request.query = {"route_key": "known"}
screen_buffer = screen_buffer_factory(["world", ""])
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
# Pretend app exists for slug "known"
server.session_manager.apps_by_slug["known"] = App(
name="Known",
slug="known",
path="./",
command="echo world",
terminal=True,
)
created = {}
async def create_session(route_key, width, height):
created["called"] = (route_key, width, height)
server.session_manager.routes["known"] = "sid"
monkeypatch.setattr(server, "_create_terminal_session", create_session)
monkeypatch.setattr(
server.session_manager,
"get_session_by_route_key",
lambda _rk: mock_session if created else None,
)
response = await server._handle_screenshot(request)
assert response.content_type == "image/svg+xml"
assert "<svg" in response.text
assert "ui-monospace" in response.text # Custom exporter uses ui-monospace font
assert created["called"][0] == "known"
assert created["called"][1:] == (132, 45)
@pytest.mark.asyncio
async def test_screenshot_returns_404_for_unknown_slug(self, server, monkeypatch, mock_request):
request = mock_request
request.query = {"route_key": "unknown"}
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: None)
with pytest.raises(web.HTTPNotFound) as exc:
await server._handle_screenshot(request)
assert exc.value.status == 404
@pytest.mark.asyncio
async def test_root_click_route_key_redirects(self, server):
request = MagicMock()
request.query = {}
server._landing_apps = [
App(name="Known", slug="known", path="./", command="echo world", terminal=True)
]
response = await server._handle_root(request)
assert "/?route_key=${encodeURIComponent(tile.slug)}" in response.text
assert "visibilitychange" in response.text
assert "searchQuery = ''" in response.text
assert "activeResultIndex = -1" in response.text
@pytest.fixture
def config(self):
"""Create a test config."""
return Config(
apps=[],
)
@pytest.fixture
def server(self, config, tmp_path):
"""Create a test server."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
return LocalServer(
config_path=str(config_file),
config=config,
host="localhost",
port=8080,
)
@pytest.mark.parametrize(
("headers", "secure", "expected_parts", "forbidden_parts"),
[
({"Host": "localhost:8080"}, False, ("ws://", "test-route"), ()),
({"Host": "localhost:8080", "X-Forwarded-Proto": "https"}, True, ("wss://",), ()),
(
{
"Host": "localhost:8080",
"X-Forwarded-Host": "example.com",
"X-Forwarded-Proto": "https",
},
False,
("example.com",),
(),
),
(
{
"Host": "localhost:8080",
"X-Forwarded-Host": "example.com",
"X-Forwarded-Port": "9000",
},
False,
("9000",),
(),
),
(
{
"Host": "example.com",
"X-Forwarded-Port": "443",
"X-Forwarded-Proto": "https",
},
True,
("wss://example.com/ws/test-route",),
(":443",),
),
],
)
def test_get_ws_url_variants(
self, server, mock_request, headers, secure, expected_parts, forbidden_parts
):
"""Test WebSocket URL generation variants."""
request = mock_request
request.headers = headers
request.secure = secure
url = server._get_ws_url_from_request(request, "test-route")
for part in expected_parts:
assert part in url
for part in forbidden_parts:
assert part not in url
class TestWebSocketProtocol:
"""Tests for WebSocket protocol message formats."""
@pytest.mark.parametrize(
("msg_type", "payload", "assertions"),
[
("stdin", "hello", lambda msg: msg[1] == "hello"),
("resize", {"width": 80, "height": 24}, lambda msg: msg[1]["width"] == 80),
("ping", "1234567890", lambda msg: msg[0] == "ping"),
],
)
def test_message_format(self, msg_type, payload, assertions):
"""Test message formats."""
msg = [msg_type, payload]
assert msg[0] == msg_type
assert assertions(msg)
class TestLocalServerMoreCoverage:
@pytest.fixture
def server_with_no_apps(self, tmp_path):
config = Config(apps=[])
config_file = tmp_path / "config.toml"
config_file.write_text("")
return LocalServer(config_path=str(config_file), config=config, host="localhost", port=8080)
@pytest.mark.asyncio
async def test_handle_health_check(self, server_with_no_apps):
resp = await server_with_no_apps._handle_health_check(MagicMock())
assert resp.text == "Local server is running"
def test_select_app_for_route_picks_default(self, server_with_no_apps, monkeypatch):
default_app = App(name="D", slug="d", path=".", command="echo d", terminal=True)
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_default_app", lambda: default_app
)
assert server_with_no_apps._select_app_for_route("missing") == default_app
@pytest.mark.asyncio
async def test_handle_session_data_no_ws_noop(self, server_with_no_apps):
await server_with_no_apps.handle_session_data("rk", b"data")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("handler", "payload"),
[
("handle_session_data", b"data"),
("handle_binary_message", b"bin"),
],
)
async def test_handle_message_sends_bytes(self, server_with_no_apps, handler, payload):
ws = MagicMock()
ws.send_bytes = AsyncMock()
queue = asyncio.Queue(maxsize=10)
server_with_no_apps._websocket_connections["rk"] = ws
server_with_no_apps._ws_send_queues["rk"] = queue
await getattr(server_with_no_apps, handler)("rk", payload)
assert await queue.get() == payload
@pytest.mark.asyncio
async def test_handle_session_close_ends_session_and_closes_ws(
self, server_with_no_apps, monkeypatch
):
ws = MagicMock()
ws.close = AsyncMock()
server_with_no_apps._websocket_connections["rk"] = ws
server_with_no_apps._ws_send_queues["rk"] = asyncio.Queue(maxsize=10)
server_with_no_apps._ws_send_tasks["rk"] = asyncio.create_task(asyncio.sleep(10))
monkeypatch.setattr(server_with_no_apps.session_manager, "on_session_end", MagicMock())
await server_with_no_apps.handle_session_close("sid", "rk")
server_with_no_apps.session_manager.on_session_end.assert_called_once_with("sid")
ws.close.assert_awaited_once()
assert "rk" not in server_with_no_apps._ws_send_tasks
def test_force_exit_sets_event(self, server_with_no_apps):
assert not server_with_no_apps.exit_event.is_set()
server_with_no_apps.force_exit()
assert server_with_no_apps.exit_event.is_set()
def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch):
from webterm import constants as constants_mod
monkeypatch.setattr(constants_mod, "WINDOWS", True)
server_with_no_apps.add_terminal("T", "cmd", "slug")
assert "slug" not in server_with_no_apps.session_manager.apps_by_slug
@pytest.mark.asyncio
async def test_handle_screenshot_404_when_no_running_session(
self, server_with_no_apps, monkeypatch
):
request = MagicMock()
request.query = {}
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_first_running_session", lambda: None
)
with pytest.raises(web.HTTPNotFound):
await server_with_no_apps._handle_screenshot(request)
@pytest.mark.asyncio
async def test_handle_screenshot_404_when_session_missing_buffer(
self, server_with_no_apps, monkeypatch
):
request = MagicMock()
request.query = {"route_key": "rk"}
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: object()
)
with pytest.raises(web.HTTPNotFound):
await server_with_no_apps._handle_screenshot(request)
@pytest.mark.asyncio
async def test_get_ws_url_falls_back_when_no_host_header(self, server_with_no_apps):
request = MagicMock()
request.headers = {}
request.secure = False
url = server_with_no_apps._get_ws_url_from_request(request, "rk")
assert url.startswith("ws://")
@pytest.mark.asyncio
async def test_root_terminal_page_includes_assets_and_dataset(
self, server_with_no_apps, monkeypatch
):
server_with_no_apps.session_manager.apps_by_slug["rk"] = App(
name="Known",
slug="rk",
path=".",
command="echo",
terminal=True,
)
request = MagicMock()
request.query = {"route_key": "rk"}
request.headers = {"Host": "localhost:8080"}
request.secure = False
resp = await server_with_no_apps._handle_root(request)
assert "/static/monospace.css" in resp.text
assert "/static/js/terminal.js" in resp.text
assert "data-session-websocket-url" in resp.text
assert "data-font-size" in resp.text
assert "data-scrollback" in resp.text
assert 'data-theme="xterm"' in resp.text
assert "<title>Known</title>" in resp.text
@pytest.mark.asyncio
async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps):
request = MagicMock()
request.headers = {"If-None-Match": "abc"}
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg></svg>")
server_with_no_apps._screenshot_cache_etag["rk"] = "abc"
with pytest.raises(web.HTTPNotModified):
server_with_no_apps._get_cached_screenshot_response(request, "rk")
@pytest.mark.asyncio
async def test_cached_screenshot_etag_sets_headers(self, server_with_no_apps):
request = MagicMock()
request.headers = {}
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg></svg>")
server_with_no_apps._screenshot_cache_etag["rk"] = "abc"
resp = server_with_no_apps._get_cached_screenshot_response(request, "rk")
assert resp is not None
assert resp.headers.get("ETag") == "abc"
def test_screenshot_cache_ttl_backs_off(self, server_with_no_apps, monkeypatch):
server_with_no_apps._route_last_activity["rk"] = 99.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 0.3
server_with_no_apps._route_last_activity["rk"] = 90.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 2.0
server_with_no_apps._route_last_activity["rk"] = 40.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 5.0
server_with_no_apps._route_last_activity["rk"] = -100.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 20.0
def test_on_keyboard_interrupt_sets_event_when_already_shutting_down(self, server_with_no_apps):
server_with_no_apps._shutdown_started = True
assert not server_with_no_apps.exit_event.is_set()
server_with_no_apps.on_keyboard_interrupt()
assert server_with_no_apps.exit_event.is_set()
@pytest.mark.asyncio
async def test_on_keyboard_interrupt_schedules_shutdown_in_running_loop(
self, server_with_no_apps
):
called = {"shutdown": False}
async def shutdown():
called["shutdown"] = True
server_with_no_apps.exit_event.set()
server_with_no_apps._shutdown = shutdown # type: ignore[method-assign]
server_with_no_apps.on_keyboard_interrupt()
assert server_with_no_apps._shutdown_task is not None
await server_with_no_apps._shutdown_task
assert called["shutdown"] is True
def test_on_keyboard_interrupt_uses_call_soon_threadsafe_when_loop_running(
self, server_with_no_apps, monkeypatch
):
async def shutdown():
return None
server_with_no_apps._shutdown = shutdown # type: ignore[method-assign]
fake_loop = MagicMock()
fake_loop.is_running = MagicMock(return_value=True)
server_with_no_apps._loop = fake_loop
created = {"called": False}
def fake_create_task(coro):
created["called"] = True
coro.close()
return MagicMock()
monkeypatch.setattr("webterm.local_server.asyncio.create_task", fake_create_task)
server_with_no_apps.on_keyboard_interrupt()
assert fake_loop.call_soon_threadsafe.called
schedule = fake_loop.call_soon_threadsafe.call_args.args[0]
schedule()
assert created["called"] is True
def test_build_routes_logs_error_when_static_path_missing(
self, server_with_no_apps, monkeypatch
):
from unittest.mock import MagicMock
from webterm import local_server
# Create a mock path that returns False for exists()
fake_path = MagicMock()
fake_path.exists.return_value = False
monkeypatch.setattr(local_server, "WEBTERM_STATIC_PATH", fake_path)
monkeypatch.setattr(local_server.log, "error", MagicMock())
server_with_no_apps._build_routes()
local_server.log.error.assert_called()
@pytest.mark.asyncio
async def test_dispatch_ws_message_stdin_without_payload_sends_empty(
self, server_with_no_apps, monkeypatch
):
session = MagicMock()
session.get_screen_has_changes = AsyncMock(return_value=False)
session.send_bytes = AsyncMock()
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
)
ws = MagicMock()
created = await server_with_no_apps._dispatch_ws_message(["stdin"], "rk", ws, False)
assert created is False
# stdin writes are fire-and-forget; wait until send_bytes is awaited
await wait_for_asyncmock_call(session.send_bytes)
session.send_bytes.assert_awaited_once_with(b"")
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_dispatch_ws_message_resize_existing_session_flag_false(
self, server_with_no_apps, monkeypatch
):
session = MagicMock()
session.get_screen_has_changes = AsyncMock(return_value=False)
session.set_terminal_size = AsyncMock()
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
)
ws = MagicMock()
created = await server_with_no_apps._dispatch_ws_message(
["resize", {"width": 100, "height": 50}], "rk", ws, False
)
assert created is False
session.set_terminal_size.assert_awaited_once_with(100, 50)
async def test_dispatch_ws_message_resize_updates_existing_session(
self, server_with_no_apps, monkeypatch
):
session = MagicMock()
session.get_screen_has_changes = AsyncMock(return_value=False)
session.set_terminal_size = AsyncMock()
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
)
ws = MagicMock()
created = await server_with_no_apps._dispatch_ws_message(
["resize", {"width": 100, "height": 50}], "rk", ws, True
)
assert created is True
session.set_terminal_size.assert_awaited_once_with(100, 50)
@pytest.mark.asyncio
async def test_dispatch_ws_message_resize_no_session_noop(
self, server_with_no_apps, monkeypatch
):
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: None
)
ws = MagicMock()
created = await server_with_no_apps._dispatch_ws_message(
["resize", {"width": 100, "height": 50}], "rk", ws, True
)
assert created is True
@pytest.mark.asyncio
async def test_handle_screenshot_uses_cached_when_no_changes(
self, server_with_no_apps, monkeypatch, mock_request, mock_session
):
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], False))
monkeypatch.setattr(
server_with_no_apps.session_manager,
"get_session_by_route_key",
lambda _rk: mock_session,
)
request = mock_request
request.query = {"route_key": "rk"}
# Seed cache
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg></svg>")
server_with_no_apps._screenshot_cache_etag["rk"] = "abc"
resp = await server_with_no_apps._handle_screenshot(request)
assert resp.text == "<svg></svg>"
@pytest.mark.asyncio
async def test_handle_screenshot_uses_screen_state(
self, server_with_no_apps, monkeypatch, screen_buffer_factory, mock_request, mock_session
):
"""Test that screenshot uses get_screen_snapshot for rendering."""
request = mock_request
request.query = {"route_key": "rk"}
screen_buffer = screen_buffer_factory(["line1", "line2"])
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
monkeypatch.setattr(
server_with_no_apps.session_manager,
"get_session_by_route_key",
lambda _rk: mock_session,
)
server_with_no_apps._route_last_activity["rk"] = 1.0
resp = await server_with_no_apps._handle_screenshot(request)
assert resp.content_type == "image/svg+xml"
assert "<svg" in resp.text
mock_session.get_screen_snapshot.assert_awaited_once()
def test_notify_activity_pushes_to_subscribers(self, server_with_no_apps):
"""Test that activity notifications are pushed to SSE subscribers."""
import asyncio
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10)
server_with_no_apps._sse_subscribers.append(queue)
server_with_no_apps._notify_activity("test-route")
assert not queue.empty()
assert queue.get_nowait() == "test-route"
def test_notify_activity_handles_full_queue(self, server_with_no_apps):
"""Test that full queues don't cause errors."""
import asyncio
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1)
queue.put_nowait("existing")
server_with_no_apps._sse_subscribers.append(queue)
# Should not raise even though queue is full
server_with_no_apps._notify_activity("test-route")
# Only the original item should be there
assert queue.get_nowait() == "existing"
@pytest.mark.asyncio
async def test_handle_session_data_marks_activity(self, server_with_no_apps, monkeypatch):
ws = MagicMock()
ws.send_bytes = AsyncMock()
server_with_no_apps._websocket_connections["rk"] = ws
server_with_no_apps._ws_send_queues["rk"] = asyncio.Queue(maxsize=10)
server_with_no_apps._route_last_activity["rk"] = 0.0
await server_with_no_apps.handle_session_data("rk", b"data")
assert server_with_no_apps._route_last_activity["rk"] > 0.0
assert await server_with_no_apps._ws_send_queues["rk"].get() == b"data"
def test_mark_route_activity_triggers_notification(self, server_with_no_apps):
"""Test that mark_route_activity triggers SSE notification."""
import asyncio
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10)
server_with_no_apps._sse_subscribers.append(queue)
server_with_no_apps.mark_route_activity("my-route")
assert not queue.empty()
assert queue.get_nowait() == "my-route"
@pytest.mark.asyncio
async def test_handle_stdin_does_not_block_ws_loop(
self, server_with_no_apps, monkeypatch
):
"""Stdin writes should be fire-and-forget so the WS loop keeps processing."""
send_started = asyncio.Event()
send_gate = asyncio.Event()
async def slow_send(_data):
send_started.set()
await send_gate.wait()
return True
session = MagicMock()
session.send_bytes = AsyncMock(side_effect=slow_send)
monkeypatch.setattr(
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
)
ws = MagicMock()
# _dispatch_ws_message should return immediately even though send_bytes blocks
created = await server_with_no_apps._dispatch_ws_message(
["stdin", "hello"], "rk", ws, False
)
assert created is False
# The background task should have been created but not finished
await send_started.wait()
assert not send_gate.is_set()
# Unblock and let the task finish
send_gate.set()
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_write_stdin_logs_timeout(
self, server_with_no_apps, monkeypatch, caplog
):
"""_write_stdin should log a warning and not raise on timeout."""
async def hang_forever(_data):
await asyncio.sleep(999)
return True
session = MagicMock()
session.send_bytes = AsyncMock(side_effect=hang_forever)
import logging
# Use a very short timeout for testing
monkeypatch.setattr("webterm.local_server.STDIN_WRITE_TIMEOUT", 0.01)
with caplog.at_level(logging.WARNING, logger="webterm"):
await server_with_no_apps._write_stdin(session, "x", "rk")
assert "Stdin write timeout" in caplog.text
@@ -1,197 +0,0 @@
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
import pytest
from aiohttp import WSMsgType, web
from aiohttp.test_utils import TestClient, TestServer
from webterm.config import App, Config
from webterm.local_server import LocalServer
from webterm.types import RouteKey, SessionID
if TYPE_CHECKING:
from collections.abc import AsyncIterator
async def _make_client(server: LocalServer) -> TestClient:
app = web.Application()
app.add_routes(server._build_routes())
test_server = TestServer(app)
client = TestClient(test_server)
await client.start_server()
return client
@pytest.fixture
def server_factory(tmp_path):
counter = {"i": 0}
def _make(apps: list[App] | None = None) -> LocalServer:
counter["i"] += 1
config = Config(
apps=apps
or [App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
)
config_file = tmp_path / f"config-{counter['i']}.toml"
config_file.write_text("")
return LocalServer(config_path=str(config_file), config=config)
return _make
@pytest.fixture
def client_factory():
@asynccontextmanager
async def _factory(server: LocalServer) -> AsyncIterator[TestClient]:
client = await _make_client(server)
try:
yield client
finally:
await client.close()
return _factory
@pytest.mark.asyncio
async def test_websocket_creates_session_on_resize(tmp_path):
config = Config(
apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
)
config_file = tmp_path / "config.toml"
config_file.write_text("")
server = LocalServer(config_path=str(config_file), config=config)
# Avoid spawning any real processes.
created = {"args": None}
async def fake_create(route_key: str, width: int, height: int) -> None:
created["args"] = (route_key, width, height)
server._create_terminal_session = fake_create # type: ignore[method-assign]
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
await ws.send_str(json.dumps(["resize", {"width": 90, "height": 25}]))
await ws.close()
finally:
await client.close()
assert created["args"] == ("test", 90, 25)
# Reconnect to an existing session should reuse it and send replay buffer
class DummySession:
def is_running(self):
return True
server.session_manager.routes["test"] = "sid"
server.session_manager.sessions["sid"] = DummySession()
# Replay buffer should be sent on reconnect
replay_session = server.session_manager.sessions["sid"]
replay_session.get_replay_buffer = AsyncMock(return_value=b"replay")
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
msg = await ws.receive(timeout=1)
assert msg.type == WSMsgType.BINARY
assert msg.data == b"replay"
await ws.close()
finally:
await client.close()
@pytest.mark.asyncio
async def test_websocket_ping_pong(tmp_path):
config = Config(
apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
)
config_file = tmp_path / "config.toml"
config_file.write_text("")
server = LocalServer(config_path=str(config_file), config=config)
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
await ws.send_str(json.dumps(["ping", "123"]))
msg = await ws.receive(timeout=1)
assert msg.type == WSMsgType.TEXT
assert json.loads(msg.data) == ["pong", "123"]
await ws.close()
finally:
await client.close()
@pytest.mark.asyncio
async def test_websocket_ignores_invalid_envelopes(tmp_path):
config = Config(
apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
)
config_file = tmp_path / "config.toml"
config_file.write_text("")
server = LocalServer(config_path=str(config_file), config=config)
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
await ws.send_str("not json")
await ws.send_str(json.dumps({"not": "a list"}))
await ws.send_str(json.dumps([]))
await ws.close()
finally:
await client.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("payload", "is_binary"),
[
("not json", False),
(json.dumps({"not": "a list"}), False),
(json.dumps([]), False),
(b"\x00\x01\x02", True),
],
)
async def test_websocket_invalid_payloads_keep_connection(
server_factory, client_factory, payload, is_binary
):
server = server_factory()
async with client_factory(server) as client:
ws = await client.ws_connect("/ws/test")
if is_binary:
await ws.send_bytes(payload)
else:
await ws.send_str(payload)
await ws.send_str(json.dumps(["ping", "ok"]))
msg = await ws.receive(timeout=1)
assert msg.type == WSMsgType.TEXT
assert json.loads(msg.data) == ["pong", "ok"]
await ws.close()
@pytest.mark.asyncio
async def test_websocket_clears_stale_session(server_factory, client_factory):
server = server_factory()
class DummySession:
def is_running(self):
return False
session_id = SessionID("sid")
route_key = RouteKey("test")
server.session_manager.routes[route_key] = session_id
server.session_manager.sessions[session_id] = DummySession()
async with client_factory(server) as client:
ws = await client.ws_connect("/ws/test")
assert server.session_manager.get_session(session_id) is None
assert server.session_manager.routes.get(route_key) is None
await ws.close()
-67
View File
@@ -1,67 +0,0 @@
"""Tests for constants module."""
class TestConstants:
"""Tests for constants module."""
def test_import(self):
"""Test module can be imported."""
from webterm import constants
assert constants is not None
def test_debug_exists(self, monkeypatch):
"""Test DEBUG constant exists and respects env var."""
import importlib
from webterm import constants
assert hasattr(constants, "DEBUG")
assert isinstance(constants.DEBUG, bool)
monkeypatch.setenv("DEBUG", "1")
reloaded = importlib.reload(constants)
assert reloaded.DEBUG is True
monkeypatch.setenv("DEBUG", "0")
reloaded = importlib.reload(constants)
assert reloaded.DEBUG is False
class TestExitPoller:
"""Tests for exit_poller module."""
def test_import(self):
"""Test module can be imported."""
from webterm.exit_poller import ExitPoller
assert ExitPoller is not None
async def test_exits_when_idle(self, monkeypatch):
"""ExitPoller should call force_exit after idle_wait seconds with no sessions."""
import asyncio
from webterm import exit_poller
from webterm.exit_poller import ExitPoller
# Speed up the poll loop for the unit test.
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
class FakeServer:
def __init__(self):
class SM:
def __init__(self):
self.sessions = {}
self.session_manager = SM()
self.exited = False
def force_exit(self):
self.exited = True
server = FakeServer()
poller = ExitPoller(server, idle_wait=0.02)
poller.start()
await asyncio.sleep(0.1)
poller.stop()
assert server.exited is True
-171
View File
@@ -1,171 +0,0 @@
"""Tests for poller module."""
import asyncio
import contextlib
from unittest.mock import MagicMock, patch
import pytest
from webterm.poller import Poller, Write
class TestWrite:
"""Tests for Write dataclass."""
def test_create_write(self):
"""Test creating a Write object."""
write = Write(data=b"test")
assert write.data == b"test"
assert write.position == 0
assert write.done_event is not None
def test_write_with_position(self):
"""Test Write with custom position."""
write = Write(data=b"test", position=5)
assert write.position == 5
class TestPoller:
"""Tests for Poller class."""
def test_init(self):
"""Test Poller initialization."""
poller = Poller()
assert poller._loop is None
assert poller._read_queues == {}
assert poller._write_queues == {}
assert not poller._exit_event.is_set()
def test_set_loop(self):
"""Test setting the asyncio loop."""
poller = Poller()
mock_loop = MagicMock()
poller.set_loop(mock_loop)
assert poller._loop == mock_loop
def test_add_file(self):
"""Test adding a file descriptor."""
poller = Poller()
# Use a mock file descriptor
with patch.object(poller._selector, "register"):
queue = poller.add_file(42)
assert 42 in poller._read_queues
assert isinstance(queue, asyncio.Queue)
def test_remove_file(self):
"""Test removing a file descriptor."""
poller = Poller()
# Add first
with patch.object(poller._selector, "register"):
poller.add_file(42)
# Remove
with patch.object(poller._selector, "unregister"):
poller.remove_file(42)
assert 42 not in poller._read_queues
def test_remove_nonexistent_file(self):
"""Test removing a non-existent file descriptor."""
poller = Poller()
with patch.object(poller._selector, "unregister"):
# Should not raise
poller.remove_file(999)
@pytest.mark.asyncio
async def test_write_handles_removed_fd(self):
poller = Poller()
poller._loop = asyncio.get_event_loop()
with patch.object(poller._selector, "register"):
poller.add_file(42)
with patch.object(poller._selector, "modify", side_effect=KeyError()):
await poller.write(42, b"test")
@pytest.mark.asyncio
async def test_write_creates_queue(self):
"""Test that write creates a write queue if needed."""
poller = Poller()
poller._loop = asyncio.get_event_loop()
# Mock selector
with patch.object(poller._selector, "register"):
poller.add_file(42)
with patch.object(poller._selector, "modify"):
# Start write in background (won't complete without poller running)
task = asyncio.create_task(poller.write(42, b"test"))
# Give it time to set up
await asyncio.sleep(0.01)
assert 42 in poller._write_queues
assert len(poller._write_queues[42]) == 1
# Cancel to clean up
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
def test_exit_sets_event(self):
"""Test that exit sets the exit event."""
poller = Poller()
poller._exit_event.clear()
# Mock join to avoid blocking
with patch.object(poller, "join"):
poller.exit()
assert poller._exit_event.is_set()
assert poller._read_queues == {}
assert poller._write_queues == {}
def test_exit_puts_none_in_queues(self):
"""Test that exit puts None in all read queues."""
poller = Poller()
# Add some queues
with patch.object(poller._selector, "register"):
q1 = poller.add_file(1)
q2 = poller.add_file(2)
# Mock join
with patch.object(poller, "join"):
poller.exit()
# Queues should have None
assert q1.get_nowait() is None
assert q2.get_nowait() is None
@pytest.mark.asyncio
async def test_write_with_timeout_returns_true_on_success(self):
"""write_with_timeout returns True when write completes."""
poller = Poller()
poller._loop = asyncio.get_event_loop()
with patch.object(poller._selector, "register"):
poller.add_file(42)
async def instant_write(fd, data):
# Simulate immediate completion
pass
with patch.object(poller, "write", side_effect=instant_write):
result = await poller.write_with_timeout(42, b"test", timeout=1.0)
assert result is True
@pytest.mark.asyncio
async def test_write_with_timeout_returns_false_on_timeout(self):
"""write_with_timeout returns False when write times out."""
poller = Poller()
poller._loop = asyncio.get_event_loop()
with patch.object(poller._selector, "register"):
poller.add_file(42)
async def slow_write(fd, data):
await asyncio.sleep(999)
with patch.object(poller, "write", side_effect=slow_write):
result = await poller.write_with_timeout(42, b"test", timeout=0.01)
assert result is False
-82
View File
@@ -1,82 +0,0 @@
"""Tests for session management."""
from __future__ import annotations
import pytest
from webterm.session import Session, SessionConnector
from webterm.types import RouteKey, SessionID
class TestSessionConnector:
"""Tests for SessionConnector base class."""
@pytest.mark.asyncio
async def test_on_data_noop(self) -> None:
"""Default on_data does nothing."""
connector = SessionConnector()
await connector.on_data(b"test") # Should not raise
@pytest.mark.asyncio
async def test_on_meta_noop(self) -> None:
"""Default on_meta does nothing."""
connector = SessionConnector()
await connector.on_meta({"key": "value"}) # Should not raise
@pytest.mark.asyncio
async def test_on_close_noop(self) -> None:
"""Default on_close does nothing."""
connector = SessionConnector()
await connector.on_close() # Should not raise
@pytest.mark.asyncio
async def test_on_binary_encoded_message_noop(self) -> None:
"""Default on_binary_encoded_message does nothing."""
connector = SessionConnector()
await connector.on_binary_encoded_message(b"\x00\x01") # Should not raise
class TestSessionBase:
"""Tests for Session base class."""
def test_is_running_default(self) -> None:
"""Default is_running returns False."""
# Session is abstract, but is_running has a default impl
# We can test it via any concrete implementation, or check the code
# For now just verify the base returns False
assert Session.is_running(Session.__new__(Session)) is False
class TestTypes:
"""Tests for type definitions."""
def test_session_id_is_string(self) -> None:
"""Test that SessionID is a string type."""
session_id = SessionID("test-session-123")
assert isinstance(session_id, str)
assert session_id == "test-session-123"
def test_route_key_is_string(self) -> None:
"""Test that RouteKey is a string type."""
route_key = RouteKey("abc123")
assert isinstance(route_key, str)
assert route_key == "abc123"
class TestIdentity:
"""Tests for identity generation."""
def test_generate_unique_ids(self) -> None:
"""Test that generated IDs are unique."""
from webterm.identity import generate
ids = [generate() for _ in range(100)]
assert len(set(ids)) == 100 # All unique
def test_generate_id_format(self) -> None:
"""Test that generated IDs have expected format."""
from webterm.identity import generate
id_ = generate()
assert isinstance(id_, str)
assert len(id_) > 0
-341
View File
@@ -1,341 +0,0 @@
"""Tests for session_manager module."""
import platform
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from webterm.config import App
from webterm.docker_watcher import AUTO_COMMAND_SENTINEL
from webterm.session_manager import SessionManager
from webterm.types import RouteKey, SessionID
class TestSessionManager:
"""Tests for SessionManager class."""
@pytest.fixture
def mock_poller(self):
"""Create a mock poller."""
return MagicMock()
@pytest.fixture
def mock_path(self, tmp_path):
"""Create a mock path."""
return tmp_path
@pytest.fixture
def sample_apps(self):
"""Create sample apps."""
return [
App(name="Test Terminal", slug="terminal", path="./", command="bash", terminal=True),
App(name="Test App", slug="app", path="./", command="python app.py", terminal=False),
]
def test_init(self, mock_poller, mock_path, sample_apps):
"""Test SessionManager initialization."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
assert manager.poller == mock_poller
assert manager.path == mock_path
assert len(manager.apps) == 2
assert "terminal" in manager.apps_by_slug
assert "app" in manager.apps_by_slug
assert len(manager.sessions) == 0
assert len(manager.routes) == 0
def test_get_default_app(self, mock_poller, mock_path, sample_apps):
"""Test getting the default app."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
assert manager.get_default_app() == sample_apps[0]
def test_get_default_app_empty(self, mock_poller, mock_path):
"""Test getting the default app when no apps are configured."""
manager = SessionManager(mock_poller, mock_path, [])
assert manager.get_default_app() is None
def test_add_app(self, mock_poller, mock_path):
"""Test adding an app."""
manager = SessionManager(mock_poller, mock_path, [])
manager.add_app("New App", "python new.py", "newapp", terminal=False)
assert len(manager.apps) == 1
assert "newapp" in manager.apps_by_slug
assert manager.apps_by_slug["newapp"].name == "New App"
def test_add_app_auto_slug(self, mock_poller, mock_path):
"""Test adding an app with auto-generated slug."""
manager = SessionManager(mock_poller, mock_path, [])
manager.add_app("Auto App", "python auto.py", "", terminal=False)
assert len(manager.apps) == 1
# Slug should be auto-generated
assert len(manager.apps[0].slug) > 0
def test_get_session_not_found(self, mock_poller, mock_path, sample_apps):
"""Test getting a non-existent session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
result = manager.get_session(SessionID("nonexistent"))
assert result is None
def test_get_session_by_route_key_not_found(self, mock_poller, mock_path, sample_apps):
"""Test getting session by non-existent route key."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
result = manager.get_session_by_route_key(RouteKey("nonexistent"))
assert result is None
def test_on_session_end(self, mock_poller, mock_path, sample_apps):
"""Test session end cleanup."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Manually add a session
session_id = SessionID("test-session")
route_key = RouteKey("test-route")
mock_session = MagicMock()
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
# End session
manager.on_session_end(session_id)
assert session_id not in manager.sessions
assert route_key not in manager.routes
def test_on_session_end_idempotent(self, mock_poller, mock_path, sample_apps):
"""Test session end cleanup is idempotent."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
session_id = SessionID("test-session")
route_key = RouteKey("test-route")
manager.sessions[session_id] = MagicMock()
manager.routes[route_key] = session_id
manager.on_session_end(session_id)
manager.on_session_end(session_id)
assert session_id not in manager.sessions
assert route_key not in manager.routes
def test_on_session_end_nonexistent(self, mock_poller, mock_path, sample_apps):
"""Test session end for non-existent session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Should not raise
manager.on_session_end(SessionID("nonexistent"))
@pytest.mark.asyncio
async def test_close_all_empty(self, mock_poller, mock_path, sample_apps):
"""Test closing all sessions when empty."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Should not raise
await manager.close_all()
@pytest.mark.asyncio
async def test_close_all_with_sessions(self, mock_poller, mock_path, sample_apps):
"""Test closing all sessions."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Add mock sessions
mock_session = MagicMock()
mock_session.close = AsyncMock()
mock_session.wait = AsyncMock()
manager.sessions[SessionID("s1")] = mock_session
await manager.close_all(timeout=1.0)
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_close_session(self, mock_poller, mock_path, sample_apps):
"""Test closing a specific session removes it from tracking."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
mock_session = MagicMock()
mock_session.close = AsyncMock()
session_id = SessionID("test-session")
route_key = RouteKey("test-route")
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
await manager.close_session(session_id)
mock_session.close.assert_called_once()
assert session_id not in manager.sessions
assert route_key not in manager.routes
@pytest.mark.asyncio
async def test_close_session_nonexistent(self, mock_poller, mock_path, sample_apps):
"""Test closing a non-existent session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Should not raise
await manager.close_session(SessionID("nonexistent"))
@pytest.mark.asyncio
async def test_new_session_no_app(self, mock_poller, mock_path):
"""Test creating session with no matching app."""
manager = SessionManager(mock_poller, mock_path, [])
result = await manager.new_session(
"nonexistent",
SessionID("test"),
RouteKey("route"),
)
assert result is None
@pytest.mark.asyncio
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
async def test_new_terminal_session(self, mock_poller, mock_path):
"""Test creating a new terminal session."""
from webterm.terminal_session import TerminalSession
app = App(name="Terminal", slug="term", path="./", command="echo test", terminal=True)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(TerminalSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"term",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, TerminalSession)
assert SessionID("test-session") in manager.sessions
assert RouteKey("test-route") in manager.routes
@pytest.mark.asyncio
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
async def test_new_docker_exec_session(self, mock_poller, mock_path):
from webterm.docker_exec_session import DockerExecSession
app = App(
name="my-container",
slug="my-container",
path="./",
command=AUTO_COMMAND_SENTINEL,
terminal=True,
)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(DockerExecSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"my-container",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, DockerExecSession)
assert result.exec_spec.user is None
async def test_new_docker_exec_session_with_user(self, mock_poller, mock_path, monkeypatch):
from webterm.docker_exec_session import DockerExecSession
monkeypatch.setenv("WEBTERM_DOCKER_USERNAME", "testuser")
app = App(
name="my-container",
slug="my-container",
path="./",
command=AUTO_COMMAND_SENTINEL,
terminal=True,
)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(DockerExecSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"my-container",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, DockerExecSession)
assert result.exec_spec.user == "testuser"
@pytest.mark.asyncio
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
async def test_new_docker_exec_session_container_placeholder(
self, mock_poller, mock_path, monkeypatch
):
"""Test that {container} placeholder in auto command is replaced with container name."""
from webterm.docker_exec_session import DockerExecSession
monkeypatch.setenv("WEBTERM_DOCKER_AUTO_COMMAND", "tmux new-session -ADs {container}")
app = App(
name="my-webapp",
slug="my-webapp",
path="./",
command=AUTO_COMMAND_SENTINEL,
terminal=True,
)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(DockerExecSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"my-webapp",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, DockerExecSession)
# Verify the container name was substituted into the command
assert result.exec_spec.command == ["tmux", "new-session", "-ADs", "my-webapp"]
class TestSessionManagerRoutes:
"""Tests for SessionManager route handling."""
@pytest.fixture
def manager(self, tmp_path):
"""Create a session manager with mock poller."""
mock_poller = MagicMock()
return SessionManager(mock_poller, tmp_path, [])
def test_route_mapping(self, manager):
"""Test route to session mapping."""
session_id = SessionID("session1")
route_key = RouteKey("route1")
manager.routes[route_key] = session_id
assert manager.routes.get(route_key) == session_id
assert manager.routes.get_key(session_id) == route_key
def test_get_session_by_route(self, manager):
"""Test getting session by route key."""
session_id = SessionID("session1")
route_key = RouteKey("route1")
mock_session = MagicMock()
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
result = manager.get_session_by_route_key(route_key)
assert result == mock_session
def test_get_first_running_session_none(self, manager):
"""Test getting first running session when empty."""
assert manager.get_first_running_session() is None
def test_get_first_running_session_found(self, manager):
"""Test getting first running session."""
session_id = SessionID("s1")
route_key = RouteKey("r1")
mock_session = MagicMock()
mock_session.is_running.return_value = True
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
result = manager.get_first_running_session()
assert result == (route_key, mock_session)
-49
View File
@@ -1,49 +0,0 @@
"""Tests for slugify module."""
from webterm.slugify import slugify
class TestSlugify:
"""Tests for the slugify function."""
def test_lowercase(self):
"""Test that slugify converts to lowercase."""
assert slugify("HelloWorld") == "helloworld"
def test_spaces_to_dashes(self):
"""Test that spaces are converted to dashes."""
assert slugify("hello world") == "hello-world"
def test_multiple_spaces(self):
"""Test that multiple spaces become single dash."""
assert slugify("hello world") == "hello-world"
def test_special_characters_removed(self):
"""Test that special characters are removed."""
assert slugify("hello@world!") == "helloworld"
def test_combined(self):
"""Test combination of transformations."""
assert slugify("Hello World!") == "hello-world"
def test_empty_string(self):
"""Test empty string."""
assert slugify("") == ""
def test_numbers_preserved(self):
"""Test that numbers are preserved."""
assert slugify("test123") == "test123"
def test_leading_trailing_spaces(self):
"""Test that leading/trailing spaces are handled."""
result = slugify(" hello ")
assert "hello" in result
def test_allow_unicode_preserves_unicode(self):
"""Test that allow_unicode=True preserves unicode characters."""
# With allow_unicode=True, unicode chars are normalized but preserved
result = slugify("héllo wörld", allow_unicode=True)
assert result == "héllo-wörld"
# Without allow_unicode (default), non-ASCII is transliterated
result_ascii = slugify("héllo wörld", allow_unicode=False)
assert result_ascii == "hello-world"
-645
View File
@@ -1,645 +0,0 @@
"""Extensive tests for the custom SVG exporter."""
from __future__ import annotations
import pytest
from webterm.svg_exporter import (
ANSI_COLORS,
DEFAULT_BG,
DEFAULT_FG,
CharData,
_color_to_hex,
_escape_xml,
render_terminal_svg,
)
class TestColorToHex:
"""Tests for _color_to_hex function."""
@pytest.mark.parametrize(
("color", "is_foreground", "expected"),
[
("default", True, DEFAULT_FG),
("default", False, DEFAULT_BG),
("#ff0000", True, "#ff0000"),
("#123456", True, "#123456"),
("#AABBCC", True, "#AABBCC"),
("ff0000", True, "#ff0000"),
("123456", True, "#123456"),
("AABBCC", True, "#AABBCC"),
("ff8700", True, "#ff8700"),
("red", True, ANSI_COLORS["red"]),
("green", True, ANSI_COLORS["green"]),
("blue", True, ANSI_COLORS["blue"]),
("white", True, ANSI_COLORS["white"]),
("black", True, ANSI_COLORS["black"]),
("brightred", True, ANSI_COLORS["brightred"]),
("brightgreen", True, ANSI_COLORS["brightgreen"]),
("brightblue", True, ANSI_COLORS["brightblue"]),
("RED", True, ANSI_COLORS["red"]),
("Green", True, ANSI_COLORS["green"]),
("BRIGHTBLUE", True, ANSI_COLORS["brightblue"]),
("unknowncolor", True, DEFAULT_FG),
("unknowncolor", False, DEFAULT_BG),
("rgb(255,0,0)", True, DEFAULT_FG),
("rgb(0,255,0)", False, DEFAULT_BG),
("gray", True, ANSI_COLORS["gray"]),
("grey", True, ANSI_COLORS["grey"]),
("lightgray", True, ANSI_COLORS["lightgray"]),
("lightgrey", True, ANSI_COLORS["lightgrey"]),
],
)
def test_color_to_hex(self, color: str, is_foreground: bool, expected: str) -> None:
"""Color conversion covers named/hex/default cases."""
assert _color_to_hex(color, is_foreground=is_foreground) == expected
def test_color_to_hex_uses_palette_defaults(self) -> None:
palette = {"red": "#123456"}
assert (
_color_to_hex(
"default",
is_foreground=True,
palette=palette,
default_fg="#111111",
default_bg="#222222",
)
== "#111111"
)
assert (
_color_to_hex(
"default",
is_foreground=False,
palette=palette,
default_fg="#111111",
default_bg="#222222",
)
== "#222222"
)
assert (
_color_to_hex(
"red",
is_foreground=True,
palette=palette,
default_fg="#111111",
default_bg="#222222",
)
== "#123456"
)
class TestEscapeXml:
"""Tests for XML escaping."""
@pytest.mark.parametrize(
("input_str", "expected"),
[
("hello world", "hello world"),
("<", "&lt;"),
("a < b", "a &lt; b"),
(">", "&gt;"),
("a > b", "a &gt; b"),
("&", "&amp;"),
("a & b", "a &amp; b"),
('"', "&quot;"),
("'", "&#x27;"),
('<script>"alert"</script>', "&lt;script&gt;&quot;alert&quot;&lt;/script&gt;"),
("你好世界", "你好世界"),
("🎉🚀", "🎉🚀"),
],
)
def test_escape_xml(self, input_str: str, expected: str) -> None:
"""Escape XML special chars and preserve unicode."""
assert _escape_xml(input_str) == expected
class TestRenderTerminalSvg:
"""Tests for render_terminal_svg function."""
def _char(
self,
data: str,
fg: str = "default",
bg: str = "default",
bold: bool = False,
italics: bool = False,
underscore: bool = False,
reverse: bool = False,
) -> CharData:
"""Helper to create CharData."""
return {
"data": data,
"fg": fg,
"bg": bg,
"bold": bold,
"italics": italics,
"underscore": underscore,
"reverse": reverse,
}
def _make_buffer(self, rows: list[str]) -> list[list[CharData]]:
"""Create simple buffer from strings."""
return [[self._char(c) for c in row] for row in rows]
def test_empty_buffer(self) -> None:
"""Empty buffer produces valid SVG."""
svg = render_terminal_svg([], width=80, height=24)
assert svg.startswith("<svg")
assert svg.endswith("</svg>")
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
def test_css_properties(self) -> None:
"""SVG includes essential CSS properties for proper rendering."""
svg = render_terminal_svg([], width=80, height=24)
# Check for legibility optimization
assert "text-rendering: optimizeLegibility" in svg
# Check for monospace font
assert "font-family:" in svg
assert "monospace" in svg
# Check for pre whitespace handling
assert "white-space: pre" in svg
def test_buffer_with_empty_rows(self) -> None:
"""Buffer with rows containing only empty cells produces valid SVG."""
# Row with only empty placeholder cells (no actual characters)
buffer = [
[self._char("") for _ in range(10)], # Empty row
[self._char("A")], # Normal row
[self._char("") for _ in range(10)], # Another empty row
]
svg = render_terminal_svg(buffer, width=10, height=3)
assert svg.startswith("<svg")
assert "A" in svg
# Should only have 1 text element with content (for "A")
assert svg.count("<tspan") == 1
def test_buffer_with_truly_empty_row(self) -> None:
"""Buffer with truly empty row (empty list) is handled."""
buffer = [
[], # Truly empty row (no cells at all)
[self._char("B")], # Normal row
]
svg = render_terminal_svg(buffer, width=10, height=2)
assert svg.startswith("<svg")
assert ">B</tspan>" in svg
def test_basic_text_output(self) -> None:
"""Basic text is included in SVG (each char with explicit x position)."""
buffer = self._make_buffer(["Hello, World!"])
svg = render_terminal_svg(buffer, width=80, height=24)
# Each character is rendered individually with explicit x
assert ">H</tspan>" in svg
assert ">e</tspan>" in svg
assert ">!</tspan>" in svg
def test_multiline_output(self) -> None:
"""Multiple lines render correctly."""
buffer = self._make_buffer(["Line 1", "Line 2", "Line 3"])
svg = render_terminal_svg(buffer, width=80, height=24)
# Check for characters from each line
assert ">L</tspan>" in svg
assert ">1</tspan>" in svg
assert ">2</tspan>" in svg
assert ">3</tspan>" in svg
# Should have 3 text elements
assert svg.count("<text y=") == 3
def test_special_chars_escaped(self) -> None:
"""Special XML characters are properly escaped."""
buffer = self._make_buffer(["<script>&test</script>"])
svg = render_terminal_svg(buffer, width=80, height=24)
assert "&lt;" in svg # < escaped
assert "&gt;" in svg # > escaped
assert "&amp;" in svg # & escaped
assert "<script>" not in svg # Should not appear unescaped
def test_colored_text(self) -> None:
"""Colored text gets fill attribute."""
buffer = [[self._char("R", fg="red"), self._char("G", fg="green")]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert f'fill="{ANSI_COLORS["red"]}"' in svg
assert f'fill="{ANSI_COLORS["green"]}"' in svg
def test_bold_text(self) -> None:
"""Bold text gets bold class."""
buffer = [[self._char("B", bold=True)]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert 'class="bold"' in svg
def test_italic_text(self) -> None:
"""Italic text gets italic class."""
buffer = [[self._char("I", italics=True)]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert 'class="italic"' in svg
def test_underline_text(self) -> None:
"""Underlined text gets underline class."""
buffer = [[self._char("U", underscore=True)]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert 'class="underline"' in svg
def test_combined_styles(self) -> None:
"""Multiple styles can be combined."""
buffer = [[self._char("X", bold=True, italics=True, underscore=True)]]
svg = render_terminal_svg(buffer, width=80, height=24)
# Should have all three classes
assert "bold" in svg
assert "italic" in svg
assert "underline" in svg
def test_background_color(self) -> None:
"""Background color creates rect element."""
buffer = [[self._char("X", bg="red")]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert f'fill="{ANSI_COLORS["red"]}"' in svg
# Should have a rect for background
assert "<rect" in svg
def test_background_color_rect_dimensions(self) -> None:
"""Background rect has correct position and dimensions."""
buffer = [[self._char("A"), self._char("B", bg="green"), self._char("C")]]
svg = render_terminal_svg(buffer, width=80, height=24, char_width=10.0)
# Background rect should be positioned after 'A' (x=10 padding + 10 char width = 20)
assert f'fill="{ANSI_COLORS["green"]}"' in svg
# Check rect exists with green fill
import re
rect_match = re.search(r'<rect[^>]*fill="{}"[^>]*/>'.format(ANSI_COLORS["green"]), svg)
assert rect_match is not None
def test_background_color_hex_format(self) -> None:
"""Background color works with hex format (with and without #)."""
buffer = [[self._char("X", bg="#ff5733")]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert 'fill="#ff5733"' in svg
def test_background_color_hex_without_hash(self) -> None:
"""Background color works with pyte 256-color format (no # prefix)."""
buffer = [[self._char("X", bg="00ff00")]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert 'fill="#00ff00"' in svg
def test_background_color_multiple_spans(self) -> None:
"""Multiple background colors in same row render correctly."""
buffer = [
[
self._char("R", bg="red"),
self._char("G", bg="green"),
self._char("B", bg="blue"),
]
]
svg = render_terminal_svg(buffer, width=80, height=24)
assert f'fill="{ANSI_COLORS["red"]}"' in svg
assert f'fill="{ANSI_COLORS["green"]}"' in svg
assert f'fill="{ANSI_COLORS["blue"]}"' in svg
# Should have 3 background rects (plus terminal bg rect)
assert svg.count("<rect") >= 4
def test_background_color_wide_char(self) -> None:
"""Background color on wide character spans correct width."""
buffer = [
[
self._char("", bg="red"),
self._char("", bg="red"), # Placeholder inherits bg
]
]
svg = render_terminal_svg(buffer, width=80, height=24, char_width=10.0)
# Background should span 2 columns (20px width + 0.5px overlap)
assert f'fill="{ANSI_COLORS["red"]}"' in svg
# Verify rect width is for 2 columns plus overlap
import re
rect_match = re.search(
r'<rect[^>]*width="(\d+\.?\d*)"[^>]*fill="{}"/>'.format(ANSI_COLORS["red"]), svg
)
assert rect_match is not None
width = float(rect_match.group(1))
assert width == 20.5 # 2 columns * 10.0 char_width + 0.5 overlap
def test_background_same_as_terminal_bg_no_rect(self) -> None:
"""Background same as terminal background doesn't create extra rect."""
# Use default terminal background (#000000)
buffer = [[self._char("X", bg="#000000")]]
svg = render_terminal_svg(buffer, width=80, height=24, background="#000000")
# Should only have terminal background rect, not character background
# Count rects - should be just 1 (terminal bg)
assert svg.count("<rect") == 1
def test_background_and_foreground_colors(self) -> None:
"""Both background and foreground colors render correctly."""
buffer = [[self._char("X", fg="yellow", bg="blue")]]
svg = render_terminal_svg(buffer, width=80, height=24)
# Background rect with blue
assert f'fill="{ANSI_COLORS["blue"]}"' in svg
# Text tspan with yellow
assert f'fill="{ANSI_COLORS["yellow"]}"' in svg
def test_box_drawing_vertical_scale(self) -> None:
"""Box-drawing characters are scaled vertically to fill line height."""
buffer = [[self._char("")]] # Vertical line
svg = render_terminal_svg(buffer, width=80, height=24)
# Box drawing chars rendered with transform for vertical scaling
assert "scale(1,1.2)" in svg
# Should be a separate text element, not a tspan
assert '<text x="' in svg
def test_box_drawing_corners(self) -> None:
"""Box-drawing corner characters are scaled."""
buffer = [[self._char(""), self._char("")]]
svg = render_terminal_svg(buffer, width=80, height=24)
# Both corners should have scale transforms
assert svg.count("scale(1,1.2)") == 2
def test_unicode_text(self) -> None:
"""Unicode text is preserved."""
buffer = self._make_buffer(["你好世界"])
svg = render_terminal_svg(buffer, width=80, height=24)
# Each char rendered separately
assert ">你</tspan>" in svg
assert ">好</tspan>" in svg
def test_emoji_text(self) -> None:
"""Emoji are preserved."""
buffer = self._make_buffer(["🎉🚀✨"])
svg = render_terminal_svg(buffer, width=80, height=24)
# Each emoji rendered separately
assert ">🎉</tspan>" in svg
assert ">🚀</tspan>" in svg
def test_wide_char_with_placeholder(self) -> None:
"""Wide chars with placeholders render correctly."""
buffer = [
[
self._char("A"),
self._char(""),
self._char(""), # Placeholder
self._char("B"),
]
]
svg = render_terminal_svg(buffer, width=80, height=24)
# Each char rendered with explicit x position
assert ">A</tspan>" in svg
assert ">中</tspan>" in svg
assert ">B</tspan>" in svg
# B should be at column 3 (A=0, 中=1-2, B=3)
assert 'x="34.0"' in svg # 10 + 3*8 = 34
def test_viewbox_dimensions(self) -> None:
"""ViewBox matches calculated dimensions."""
svg = render_terminal_svg([], width=80, height=24)
assert 'viewBox="0 0' in svg
def test_title_included(self) -> None:
"""Title is included in SVG."""
svg = render_terminal_svg([], width=80, height=24, title="My Terminal")
assert "<title>My Terminal</title>" in svg
def test_title_escaped(self) -> None:
"""Title with special chars is escaped."""
svg = render_terminal_svg([], width=80, height=24, title="<Test>")
assert "<title>&lt;Test&gt;</title>" in svg
def test_custom_font_size(self) -> None:
"""Custom font size is applied."""
svg = render_terminal_svg([], width=80, height=24, font_size=16)
assert "font-size: 16px" in svg
def test_custom_background(self) -> None:
"""Custom background color is applied."""
svg = render_terminal_svg([], width=80, height=24, background="#1a1a1a")
assert "fill: #1a1a1a" in svg
def test_style_definitions_present(self) -> None:
"""CSS style definitions are included."""
svg = render_terminal_svg([], width=80, height=24)
assert "<style>" in svg
assert ".terminal-bg" in svg
assert ".terminal-text" in svg
assert ".bold" in svg
assert ".italic" in svg
assert ".underline" in svg
def test_full_screen_render(self) -> None:
"""Full terminal screen renders without error."""
# Create a 80x24 screen with various content
buffer: list[list[CharData]] = []
for row in range(24):
row_data: list[CharData] = []
for col in range(80):
char = chr(32 + ((row * 80 + col) % 95)) # Printable ASCII
row_data.append(self._char(char))
buffer.append(row_data)
svg = render_terminal_svg(buffer, width=80, height=24)
assert svg.startswith("<svg")
assert svg.endswith("</svg>")
# Should have 24 text elements
assert svg.count("<text y=") == 24
def test_reverse_video_rendering(self) -> None:
"""Reverse video swaps colors correctly."""
buffer = [[self._char("X", fg="white", bg="black", reverse=True)]]
svg = render_terminal_svg(buffer, width=80, height=24)
# Colors should be swapped, so fg should be black's color
assert ANSI_COLORS["black"] in svg
def test_hex_color_passthrough(self) -> None:
"""Hex colors in buffer pass through to SVG."""
buffer = [[self._char("X", fg="#ff5733")]]
svg = render_terminal_svg(buffer, width=80, height=24)
assert 'fill="#ff5733"' in svg
def test_whitespace_handling(self) -> None:
"""Whitespace is preserved."""
buffer = self._make_buffer([" indented text "])
svg = render_terminal_svg(buffer, width=80, height=24)
# white-space: pre should be in styles
assert "white-space: pre" in svg
class TestSvgStructure:
"""Tests for SVG document structure."""
def test_valid_xml_structure(self) -> None:
"""SVG has valid XML structure."""
svg = render_terminal_svg([], width=80, height=24)
# Basic structure checks
assert svg.count("<svg") == 1
assert svg.count("</svg>") == 1
assert svg.count("<defs>") == 1
assert svg.count("</defs>") == 1
assert svg.count("<style>") == 1
assert svg.count("</style>") == 1
def test_all_tags_closed(self) -> None:
"""All opened tags are properly closed."""
buffer = [
[
{
"data": "X",
"fg": "red",
"bg": "blue",
"bold": True,
"italics": False,
"underscore": False,
"reverse": False,
}
]
]
svg = render_terminal_svg(buffer, width=80, height=24)
# Count opening and closing tags
assert svg.count("<g") == svg.count("</g>")
assert svg.count("<text") == svg.count("</text>")
def test_namespace_declared(self) -> None:
"""SVG namespace is properly declared."""
svg = render_terminal_svg([], width=80, height=24)
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def _char(
self,
data: str,
fg: str = "default",
bg: str = "default",
bold: bool = False,
italics: bool = False,
underscore: bool = False,
reverse: bool = False,
) -> CharData:
"""Helper to create CharData."""
return {
"data": data,
"fg": fg,
"bg": bg,
"bold": bold,
"italics": italics,
"underscore": underscore,
"reverse": reverse,
}
def test_single_cell(self) -> None:
"""Single cell terminal renders."""
buffer = [[self._char("X")]]
svg = render_terminal_svg(buffer, width=1, height=1)
assert "X" in svg
def test_very_wide_terminal(self) -> None:
"""Very wide terminal (200 cols) renders."""
row = [self._char("X") for _ in range(200)]
svg = render_terminal_svg([row], width=200, height=1)
assert svg.startswith("<svg")
def test_very_tall_terminal(self) -> None:
"""Very tall terminal (100 rows) renders."""
buffer = [[self._char("X")] for _ in range(100)]
svg = render_terminal_svg(buffer, width=1, height=100)
assert svg.count("<text y=") == 100
def test_all_spaces(self) -> None:
"""Row of all spaces renders."""
buffer = [[self._char(" ") for _ in range(80)]]
svg = render_terminal_svg(buffer, width=80, height=1)
assert svg.startswith("<svg")
def test_null_chars_as_space(self) -> None:
"""Null characters (empty string) are skipped."""
buffer = [[self._char(""), self._char("A"), self._char("")]]
svg = render_terminal_svg(buffer, width=3, height=1)
assert "A" in svg
def test_mixed_width_characters(self) -> None:
"""Mix of narrow and wide characters."""
buffer = [
[
self._char("A"),
self._char(""),
self._char(""),
self._char("B"),
self._char("🎉"),
self._char(""),
self._char("C"),
]
]
svg = render_terminal_svg(buffer, width=7, height=1)
# Each char rendered with explicit x
assert ">A</tspan>" in svg
assert ">中</tspan>" in svg
assert ">B</tspan>" in svg
assert ">🎉</tspan>" in svg
assert ">C</tspan>" in svg
def test_special_unicode_blocks(self) -> None:
"""Unicode box drawing characters render (separately for precise positioning)."""
buffer = [
[
self._char(""),
self._char(""),
self._char(""),
]
]
svg = render_terminal_svg(buffer, width=3, height=1)
# Box drawing chars are rendered separately for precise x positioning
assert "" in svg
assert "" in svg
assert "" in svg
def test_horizontal_lines_render_without_textlength(self) -> None:
"""Horizontal lines render without textLength (removed due to positioning issues)."""
buffer = [
[
self._char(""),
self._char(""),
self._char(""),
self._char(""),
self._char(""),
]
]
svg = render_terminal_svg(buffer, width=5, height=1)
# Horizontal lines should NOT have textLength (causes visual offset issues)
assert "textLength=" not in svg
assert "lengthAdjust=" not in svg
# But the characters should still be present
assert "" in svg or "───" in svg
def test_ansi_bright_colors(self) -> None:
"""All bright ANSI colors render."""
colors = [
"brightred",
"brightgreen",
"brightyellow",
"brightblue",
"brightmagenta",
"brightcyan",
]
buffer = [[self._char("X", fg=c) for c in colors]]
svg = render_terminal_svg(buffer, width=len(colors), height=1)
for color in colors:
assert ANSI_COLORS[color] in svg
def test_rapid_color_changes(self) -> None:
"""Rapid color changes (each char different) render."""
colors = ["red", "green", "blue", "yellow", "magenta", "cyan"]
buffer = [[self._char(str(i), fg=colors[i % len(colors)]) for i in range(20)]]
svg = render_terminal_svg(buffer, width=20, height=1)
# Should have multiple tspan elements
assert svg.count("<tspan") >= 1
def test_all_attributes_at_once(self) -> None:
"""Character with all attributes renders."""
buffer = [
[
self._char(
"X", fg="red", bg="blue", bold=True, italics=True, underscore=True, reverse=True
)
]
]
svg = render_terminal_svg(buffer, width=1, height=1)
assert "bold" in svg
assert "italic" in svg
assert "underline" in svg
-549
View File
@@ -1,549 +0,0 @@
"""Tests for terminal_session module."""
import asyncio
import os
import platform
import pty
import shlex
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from webterm.terminal_session import (
REPLAY_BUFFER_SIZE,
TerminalSession,
)
# Skip tests on Windows
pytestmark = pytest.mark.skipif(
platform.system() == "Windows",
reason="Terminal sessions not supported on Windows",
)
@pytest.fixture
def terminal_session(mock_poller):
"""Create a TerminalSession for testing."""
return TerminalSession(mock_poller, "test-session", "bash")
class TestTerminalSession:
"""Tests for TerminalSession class."""
def test_import(self):
"""Test that module can be imported."""
assert TerminalSession is not None
def test_replay_buffer_size(self):
"""Test replay buffer size constant."""
assert REPLAY_BUFFER_SIZE == 256 * 1024
def test_init(self, terminal_session):
"""Test TerminalSession initialization."""
assert terminal_session.session_id == "test-session"
assert terminal_session.command == "bash"
assert terminal_session.master_fd is None
assert terminal_session.pid is None
assert terminal_session._task is None
def test_init_default_shell(self, mock_poller):
"""Test that default shell is used when command is empty."""
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
session = TerminalSession(mock_poller, "test-session", "")
assert session.command == "/bin/zsh"
def test_package_version_fallback(self):
with (
patch("webterm.terminal_session.version", side_effect=RuntimeError()),
patch("webterm.terminal_session.PackageNotFoundError", RuntimeError),
):
assert TerminalSession._package_version() == "0.0.0"
@pytest.mark.asyncio
async def test_replay_buffer_add(self, terminal_session):
"""Test adding data to replay buffer."""
await terminal_session._add_to_replay_buffer(b"test data")
assert terminal_session._replay_buffer_size == 9
assert await terminal_session.get_replay_buffer() == b"test data"
@pytest.mark.asyncio
async def test_replay_buffer_multiple_adds(self, terminal_session):
"""Test adding multiple chunks to replay buffer."""
await terminal_session._add_to_replay_buffer(b"chunk1")
await terminal_session._add_to_replay_buffer(b"chunk2")
assert await terminal_session.get_replay_buffer() == b"chunk1chunk2"
@pytest.mark.asyncio
async def test_replay_buffer_overflow(self, terminal_session):
"""Test that replay buffer trims old data when exceeding limit."""
# Add more data than buffer size
chunk_size = 1024
for _i in range(100): # 100KB total
await terminal_session._add_to_replay_buffer(b"x" * chunk_size)
# Buffer should be trimmed
assert terminal_session._replay_buffer_size <= REPLAY_BUFFER_SIZE + chunk_size
@pytest.mark.asyncio
async def test_screen_state_updates_with_data(self, terminal_session):
"""Test that pyte screen updates when data is received."""
await terminal_session._update_screen(b"Hello World\r\n")
lines = await terminal_session.get_screen_lines()
assert "Hello World" in lines[0]
@pytest.mark.asyncio
async def test_screen_handles_cursor_positioning(self, terminal_session):
"""Test that pyte screen correctly handles cursor positioning (tmux-style)."""
await terminal_session._update_screen(b"Line 1\r\nLine 2\r\nLine 3\r\n")
# Move cursor to line 2, column 1 and clear line, then write new content
await terminal_session._update_screen(b"\x1b[2;1H\x1b[KUpdated Line 2")
lines = await terminal_session.get_screen_lines()
assert lines[0] == "Line 1"
assert lines[1] == "Updated Line 2"
assert lines[2] == "Line 3"
@pytest.mark.asyncio
async def test_screen_handles_c1_csi_sequences(self, terminal_session):
"""Ensure C1 CSI (0x9b) sequences are parsed for clearing lines."""
await terminal_session._update_screen(b"Line 1\r\nLine 2\r\nLine 3\r\n")
# C1 CSI equivalent of ESC[2;1H ESC[K
await terminal_session._update_screen(b"\x9b2;1H\x9bKUpdated Line 2")
lines = await terminal_session.get_screen_lines()
assert lines[0] == "Line 1"
assert lines[1] == "Updated Line 2"
assert lines[2] == "Line 3"
@pytest.mark.asyncio
async def test_screen_preserves_utf8_bytes_with_c1_values(self, terminal_session):
"""Ensure UTF-8 bytes containing 0x9c aren't corrupted by C1 normalization."""
await terminal_session._update_screen("✓ ok\r\n".encode())
lines = await terminal_session.get_screen_lines()
assert "✓ ok" in lines[0]
@pytest.mark.asyncio
async def test_get_screen_state_returns_dirty_flag(self, terminal_session):
"""Test that get_screen_state returns has_changes flag based on pyte dirty tracking."""
# After creation, all rows are dirty (initialized)
_w, _h, _buf, has_changes = await terminal_session.get_screen_state()
assert has_changes is True
# After getting state, dirty set is cleared
_, _, _, has_changes = await terminal_session.get_screen_state()
assert has_changes is False
# Feed new data
await terminal_session._update_screen(b"New content\r\n")
_, _, _, has_changes = await terminal_session.get_screen_state()
assert has_changes is True
# Check again without new data
_, _, _, has_changes = await terminal_session.get_screen_state()
assert has_changes is False
def test_update_connector(self, terminal_session):
"""Test updating connector."""
mock_connector = MagicMock()
terminal_session.update_connector(mock_connector)
assert terminal_session._connector == mock_connector
def test_is_running_not_started(self, terminal_session):
"""Test is_running when session not started."""
assert terminal_session.is_running() is False
@pytest.mark.asyncio
async def test_send_bytes_no_fd(self, terminal_session):
"""Test send_bytes returns False when no master_fd."""
result = await terminal_session.send_bytes(b"test")
assert result is False
@pytest.mark.asyncio
async def test_send_meta(self, terminal_session):
"""Test send_meta returns True."""
result = await terminal_session.send_meta({})
assert result is True
@pytest.mark.asyncio
async def test_close_no_pid(self, terminal_session):
"""Test close when no pid."""
await terminal_session.close() # Should not raise
@pytest.mark.asyncio
async def test_wait_no_task(self, terminal_session):
"""Test wait when no task."""
await terminal_session.wait() # Should not raise
def test_repr(self, terminal_session):
"""Test repr output."""
repr_str = repr(terminal_session)
assert "test-session" in repr_str
assert "bash" in repr_str
@pytest.mark.asyncio
async def test_open_uses_shlex_split_and_execvp_with_args(self, mock_poller):
command = 'echo "hello world"'
session = TerminalSession(mock_poller, "test-session", command)
with (
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)) as mock_fork,
patch("webterm.terminal_session.version", return_value="0.0.0"),
patch("webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
patch("webterm.terminal_session.os.execvp", side_effect=OSError()) as mock_execvp,
patch("webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
pytest.raises(SystemExit),
):
await session.open()
mock_fork.assert_called_once()
mock_split.assert_called_once_with(command)
mock_execvp.assert_called_once_with("echo", ["echo", "hello world"])
mock_exit.assert_called_once_with(1)
@pytest.mark.asyncio
async def test_open_parent_branch_sets_fd_and_pid(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
with (
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
patch.object(session, "_set_terminal_size") as set_size,
):
await session.open(width=80, height=24)
assert session.pid == 1234
assert session.master_fd == 99
set_size.assert_called_once_with(80, 24)
@pytest.mark.asyncio
async def test_open_bad_command_exits(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bad")
with (
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
patch("webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
patch("webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
pytest.raises(SystemExit),
):
await session.open()
mock_exit.assert_called_once_with(1)
@pytest.mark.asyncio
async def test_get_screen_lines_strips(self, terminal_session, dummy_lock):
terminal_session._screen = MagicMock()
terminal_session._screen.display = ["line ", "next"]
terminal_session._screen_lock = dummy_lock
lines = await terminal_session.get_screen_lines()
assert lines == ["line", "next"]
@pytest.mark.asyncio
async def test_get_screen_state_no_changes(
self, terminal_session, dummy_lock, mock_screen_char
):
terminal_session._screen = MagicMock()
terminal_session._screen.columns = 1
terminal_session._screen.lines = 1
terminal_session._screen.dirty = set()
terminal_session._screen.buffer = [[mock_screen_char()]]
terminal_session._sync_pyte_to_pty = AsyncMock()
terminal_session._screen_lock = dummy_lock
width, height, _buffer, changed = await terminal_session.get_screen_state()
assert width == 1
assert height == 1
assert changed is False
@pytest.mark.asyncio
async def test_get_screen_state_clears_dirty(
self, terminal_session, dummy_lock, mock_screen_char
):
terminal_session._screen = MagicMock()
terminal_session._screen.columns = 2
terminal_session._screen.lines = 1
terminal_session._screen.dirty = {1}
terminal_session._screen.buffer = [[mock_screen_char("x"), mock_screen_char("y")]]
terminal_session._sync_pyte_to_pty = AsyncMock()
terminal_session._screen_lock = dummy_lock
width, height, _buffer, changed = await terminal_session.get_screen_state()
assert width == 2
assert height == 1
assert changed is True
assert terminal_session._screen.dirty == set()
@pytest.mark.asyncio
async def test_get_screen_snapshot_does_not_mutate_state(
self, terminal_session, dummy_lock, mock_screen_char
):
"""Test that get_screen_snapshot doesn't call _sync_pyte_to_pty or clear dirty."""
terminal_session._screen = MagicMock()
terminal_session._screen.columns = 2
terminal_session._screen.lines = 1
terminal_session._screen.dirty = {0}
terminal_session._screen.buffer = [[mock_screen_char("a"), mock_screen_char("b")]]
terminal_session._sync_pyte_to_pty = AsyncMock()
terminal_session._screen_lock = dummy_lock
terminal_session._change_counter = 1
terminal_session._last_snapshot_counter = 0
width, height, buffer, has_changes = await terminal_session.get_screen_snapshot()
# Verify dimensions and data returned correctly
assert width == 2
assert height == 1
assert has_changes is True
assert buffer[0][0]["data"] == "a"
assert buffer[0][1]["data"] == "b"
# Verify no mutation: _sync_pyte_to_pty not called, dirty not cleared
terminal_session._sync_pyte_to_pty.assert_not_awaited()
assert terminal_session._screen.dirty == {0} # NOT cleared
# Snapshot counter should be updated for change tracking
assert terminal_session._last_snapshot_counter == 1
@pytest.mark.asyncio
async def test_get_screen_snapshot_tracks_changes_correctly(self, terminal_session, dummy_lock):
"""Test that repeated snapshots correctly track changes."""
terminal_session._screen_lock = dummy_lock
terminal_session._change_counter = 5
terminal_session._last_snapshot_counter = 5
# No changes since last snapshot
_, _, _, has_changes = await terminal_session.get_screen_snapshot()
assert has_changes is False
# Simulate new screen data
terminal_session._change_counter = 6
_, _, _, has_changes = await terminal_session.get_screen_snapshot()
assert has_changes is True
assert terminal_session._last_snapshot_counter == 6
@pytest.mark.asyncio
async def test_update_screen_increments_change_counter(self, terminal_session):
"""Test that _update_screen increments change counter when screen changes."""
initial_counter = terminal_session._change_counter
# Feed data that will mark screen as dirty
await terminal_session._update_screen(b"Hello\r\n")
assert terminal_session._change_counter > initial_counter
@pytest.mark.asyncio
async def test_set_terminal_size_increments_change_counter(self, terminal_session, mock_poller):
"""Test that set_terminal_size increments change counter."""
terminal_session.master_fd = 10
initial_counter = terminal_session._change_counter
loop = asyncio.get_running_loop()
with patch.object(loop, "run_in_executor", new=AsyncMock()):
await terminal_session.set_terminal_size(100, 50)
assert terminal_session._change_counter == initial_counter + 1
@pytest.mark.asyncio
async def test_get_screen_has_changes_uses_change_counter(self, terminal_session, dummy_lock):
"""Test that get_screen_has_changes uses the change counter."""
terminal_session._screen_lock = dummy_lock
# Initially no changes
terminal_session._change_counter = 0
terminal_session._last_snapshot_counter = 0
assert await terminal_session.get_screen_has_changes() is False
# After screen update increments counter
terminal_session._change_counter = 1
assert await terminal_session.get_screen_has_changes() is True
# After snapshot resets detection
terminal_session._last_snapshot_counter = 1
assert await terminal_session.get_screen_has_changes() is False
@pytest.mark.asyncio
async def test_send_bytes_handles_closed_fd(self, mock_poller):
mock_poller.write = AsyncMock(side_effect=KeyError)
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
ok = await session.send_bytes(b"test")
assert ok is False
@pytest.mark.asyncio
async def test_run_reads_from_poller_and_closes(self, mock_poller):
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
await queue.put(b"hello")
await queue.put(None)
mock_poller.add_file = MagicMock(return_value=queue)
mock_poller.remove_file = MagicMock()
connector = MagicMock()
connector.on_data = AsyncMock()
connector.on_close = AsyncMock()
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
session._connector = connector
with patch("webterm.terminal_session.os.close") as mock_close:
await session.run()
connector.on_data.assert_awaited_once_with(b"hello")
connector.on_close.assert_awaited_once()
mock_poller.remove_file.assert_called_once_with(10)
mock_close.assert_called_once_with(10)
@pytest.mark.asyncio
async def test_start_updates_connector_when_already_running(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
existing = asyncio.create_task(asyncio.sleep(0))
session._task = existing
connector = MagicMock()
task = await session.start(connector)
assert task is existing
assert session._connector is connector
await existing
@pytest.mark.asyncio
async def test_send_bytes_writes_via_poller(self, mock_poller):
mock_poller.write = AsyncMock()
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
assert await session.send_bytes(b"x") is True
mock_poller.write.assert_awaited_once_with(10, b"x")
@pytest.mark.asyncio
async def test_open_set_terminal_size_oserror_closes_fd_and_clears_master_fd(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
with (
patch("webterm.terminal_session.pty.fork", return_value=(1234, 99)),
patch.object(session, "_set_terminal_size", side_effect=OSError("bad")),
patch("webterm.terminal_session.os.close") as mock_close,
pytest.raises(OSError),
):
await session.open(width=80, height=24)
mock_close.assert_called_once_with(99)
assert session.master_fd is None
@pytest.mark.asyncio
async def test_set_terminal_size_uses_executor(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
loop = asyncio.get_running_loop()
with patch.object(loop, "run_in_executor", new=AsyncMock()) as run_in_executor:
await 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, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
with patch("webterm.terminal_session.fcntl.ioctl") as mock_ioctl:
session._set_terminal_size(80, 24)
assert mock_ioctl.called
@pytest.mark.asyncio
async def test_start_creates_task_when_not_running(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
session.run = AsyncMock() # type: ignore[method-assign]
connector = MagicMock()
task = await session.start(connector)
assert task is session._task
assert session._connector is connector
await task
session.run.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_without_connector_still_closes(self, mock_poller):
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
await queue.put(b"hello")
await queue.put(None)
mock_poller.add_file = MagicMock(return_value=queue)
mock_poller.remove_file = MagicMock()
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
session._connector = None
with patch("webterm.terminal_session.os.close") as mock_close:
await session.run()
mock_poller.remove_file.assert_called_once_with(10)
mock_close.assert_called_once_with(10)
@pytest.mark.asyncio
async def test_run_oserror_still_closes(self, mock_poller):
queue = MagicMock()
queue.get = AsyncMock(side_effect=OSError("boom"))
mock_poller.add_file = MagicMock(return_value=queue)
mock_poller.remove_file = MagicMock()
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
session._connector = None
with patch("webterm.terminal_session.os.close") as mock_close:
await session.run()
mock_poller.remove_file.assert_called_once_with(10)
mock_close.assert_called_once_with(10)
@pytest.mark.asyncio
async def test_close_process_lookup_error_is_ignored(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.pid = 123
with patch("webterm.terminal_session.os.kill", side_effect=ProcessLookupError()):
await session.close()
@pytest.mark.asyncio
async def test_close_logs_warning_on_unexpected_exception(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.pid = 123
with (
patch("webterm.terminal_session.os.kill", side_effect=RuntimeError("x")),
patch("webterm.terminal_session.log.warning") as warn,
):
await session.close()
assert warn.called
@pytest.mark.asyncio
async def test_wait_suppresses_cancelled_error(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
task = asyncio.create_task(asyncio.sleep(10))
task.cancel()
session._task = task
await session.wait()
def test_is_running_false_when_kill_fails(self, mock_poller):
session = TerminalSession(mock_poller, "sid", "bash")
session.master_fd = 10
session._task = MagicMock()
session.pid = 123
with patch("webterm.terminal_session.os.kill", side_effect=OSError()):
assert session.is_running() is False
-89
View File
@@ -1,89 +0,0 @@
"""Tests for TwoWayDict."""
from __future__ import annotations
import pytest
from webterm._two_way_dict import TwoWayDict
class TestTwoWayDict:
"""Tests for TwoWayDict bidirectional mapping."""
def test_set_and_get(self) -> None:
"""Test basic set and get operations."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["b"] = 2
assert d.get("a") == 1
assert d.get("b") == 2
def test_get_key(self) -> None:
"""Test reverse lookup by value."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["b"] = 2
assert d.get_key(1) == "a"
assert d.get_key(2) == "b"
def test_delete(self) -> None:
"""Test deletion removes both mappings."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
del d["a"]
assert d.get("a") is None
assert d.get_key(1) is None
def test_contains(self) -> None:
"""Test key containment check."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
assert "a" in d
assert "b" not in d
def test_contains_value(self) -> None:
"""Test value containment check."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
assert d.contains_value(1) is True
assert d.contains_value(2) is False
def test_len(self) -> None:
"""Test length of dictionary."""
d: TwoWayDict[str, int] = TwoWayDict()
assert len(d) == 0
d["a"] = 1
assert len(d) == 1
d["b"] = 2
assert len(d) == 2
def test_iter(self) -> None:
"""Test iteration over keys."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["b"] = 2
keys = list(d)
assert "a" in keys
assert "b" in keys
def test_initial_data(self) -> None:
"""Test initialization with data."""
d: TwoWayDict[str, int] = TwoWayDict({"a": 1, "b": 2})
assert d.get("a") == 1
assert d.get_key(2) == "b"
def test_reassign_key_removes_old_reverse(self) -> None:
"""Test reassigning a key removes the old reverse mapping."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["a"] = 2 # Reassign key "a" to value 2
assert d.get("a") == 2
assert d.get_key(2) == "a"
assert d.get_key(1) is None # Old value should be unmapped
def test_duplicate_value_raises(self) -> None:
"""Test that assigning duplicate value to different key raises."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
with pytest.raises(ValueError, match="already mapped"):
d["b"] = 1 # Same value, different key
-61
View File
@@ -1,61 +0,0 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from webterm.config import Config
from webterm.local_server import WS_SEND_TIMEOUT, LocalServer
@pytest.mark.asyncio
async def test_ws_sender_flushes_queue():
server = LocalServer(config_path="./", config=Config(apps=[]), host="localhost", port=8080)
ws = MagicMock()
ws.send_bytes = AsyncMock()
ws.closed = False
ws.close = AsyncMock()
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
sender_task = asyncio.create_task(server._ws_sender("rk", ws, queue))
await queue.put(b"hello")
await queue.put(b"world")
await queue.put(None)
await sender_task
ws.send_bytes.assert_any_await(b"hello")
ws.send_bytes.assert_any_await(b"world")
@pytest.mark.asyncio
async def test_ws_sender_timeout_closes():
server = LocalServer(config_path="./", config=Config(apps=[]), host="localhost", port=8080)
ws = MagicMock()
ws.closed = False
ws.close = AsyncMock()
async def slow_send(_data):
await asyncio.sleep(WS_SEND_TIMEOUT * 2)
ws.send_bytes = AsyncMock(side_effect=slow_send)
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
sender_task = asyncio.create_task(server._ws_sender("rk", ws, queue))
await queue.put(b"slow")
await sender_task
ws.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_enqueue_ws_data_drops_oldest_when_full():
server = LocalServer(config_path="./", config=Config(apps=[]), host="localhost", port=8080)
queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
server._ws_send_queues["rk"] = queue
queue.put_nowait(b"first")
server._enqueue_ws_data("rk", b"second")
assert queue.qsize() == 1
assert await queue.get() == b"second"
+1 -1
View File
@@ -12,6 +12,6 @@
"isolatedModules": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/webterm/static/js/**/*.ts"],
"include": ["go/webterm/static/js/**/*.ts"],
"exclude": ["node_modules"]
}