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 [32mBundled 3 modules in 10ms[0m [34mterminal.js[33m 0.68 MB [2m(entry point)[0m (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:
+5
-29
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -1,289 +1,118 @@
|
||||
# webterm
|
||||
# webterm (Go)
|
||||
|
||||

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

|
||||
|
||||
## 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
@@ -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
@@ -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 aren’t 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
|
||||
@@ -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 1–2).
|
||||
- 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.
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}()
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
-117
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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("-_")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
"""Tests for webterm."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
("<", "<"),
|
||||
("a < b", "a < b"),
|
||||
(">", ">"),
|
||||
("a > b", "a > b"),
|
||||
("&", "&"),
|
||||
("a & b", "a & b"),
|
||||
('"', """),
|
||||
("'", "'"),
|
||||
('<script>"alert"</script>', "<script>"alert"</script>"),
|
||||
("你好世界", "你好世界"),
|
||||
("🎉🚀", "🎉🚀"),
|
||||
],
|
||||
)
|
||||
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 "<" in svg # < escaped
|
||||
assert ">" in svg # > escaped
|
||||
assert "&" 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><Test></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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user