Add .gitignore and initial project files
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tmux_shot import TmuxApp
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEMO_TUI = REPO_ROOT / "examples" / "demo_tui.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_name() -> str:
|
||||
return f"tmux_shot_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def demo_app(session_name: str, tmp_path: Path):
|
||||
app = TmuxApp(session_name, command=f"python3 {DEMO_TUI}", width=80, height=24)
|
||||
try:
|
||||
yield app
|
||||
finally:
|
||||
try:
|
||||
app.kill()
|
||||
except Exception:
|
||||
pass # already dead (e.g. the test quit it itself)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def out_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "out"
|
||||
d.mkdir()
|
||||
return d
|
||||
@@ -0,0 +1,370 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from tmux_shot import TmuxApp
|
||||
from tmux_shot.agent import AgentRunner, _split_run_on_chunk
|
||||
|
||||
|
||||
class FakeFunction:
|
||||
def __init__(self, name: str, arguments: str) -> None:
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
|
||||
class FakeToolCall:
|
||||
def __init__(self, call_id: str, name: str, arguments: str) -> None:
|
||||
self.id = call_id
|
||||
self.function = FakeFunction(name, arguments)
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, content: str | None = None, tool_calls: list[FakeToolCall] | None = None) -> None:
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls or []
|
||||
|
||||
def model_dump(self, exclude_none: bool = True) -> dict[str, Any]:
|
||||
d: dict[str, Any] = {"role": "assistant", "content": self.content}
|
||||
if self.tool_calls:
|
||||
d["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
||||
}
|
||||
for tc in self.tool_calls
|
||||
]
|
||||
if exclude_none:
|
||||
d = {k: v for k, v in d.items() if v is not None}
|
||||
return d
|
||||
|
||||
|
||||
class FakeCompletions:
|
||||
def __init__(self, script: list[FakeMessage]) -> None:
|
||||
self._script = list(script)
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
|
||||
def create(self, **kwargs: Any):
|
||||
# snapshot messages: it's the same mutable list AgentRunner keeps appending
|
||||
# to for the rest of the run, so store a copy or every recorded request
|
||||
# would end up reflecting the final state instead of the state at call time.
|
||||
self.requests.append({**kwargs, "messages": list(kwargs["messages"])})
|
||||
item = self._script.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return type("R", (), {"choices": [type("C", (), {"message": item})()]})()
|
||||
|
||||
|
||||
class FakeChat:
|
||||
def __init__(self, script: list[FakeMessage]) -> None:
|
||||
self.completions = FakeCompletions(script)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, script: list[FakeMessage]) -> None:
|
||||
self.chat = FakeChat(script)
|
||||
|
||||
|
||||
def test_agent_runner_drives_app_via_scripted_tool_calls(demo_app: TmuxApp, out_dir: Path) -> None:
|
||||
time.sleep(0.3)
|
||||
|
||||
script = [
|
||||
FakeMessage(
|
||||
content="Moving down.",
|
||||
tool_calls=[FakeToolCall("call_1", "send_keys", '{"keys": "Down Down", "enter": false}')],
|
||||
),
|
||||
FakeMessage(
|
||||
tool_calls=[FakeToolCall("call_2", "screenshot", '{"label": "moved"}')],
|
||||
),
|
||||
FakeMessage(
|
||||
tool_calls=[FakeToolCall("call_3", "finish", '{"summary": "moved and captured"}')],
|
||||
),
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.05, max_steps=5)
|
||||
runner.run("move down twice and screenshot it")
|
||||
|
||||
assert len(runner.shots) == 1
|
||||
assert Path(runner.shots[0]).exists()
|
||||
|
||||
# 3 request round-trips: send_keys turn, screenshot turn, finish turn
|
||||
assert len(client.chat.completions.requests) == 3
|
||||
|
||||
# the request after the screenshot tool call must include the image as a follow-up
|
||||
# user message, immediately after that turn's tool-response message
|
||||
third_request_messages = client.chat.completions.requests[2]["messages"]
|
||||
roles = [m["role"] for m in third_request_messages]
|
||||
assert roles.count("tool") == 2 # one per completed tool call so far
|
||||
image_messages = [
|
||||
m for m in third_request_messages if m["role"] == "user" and isinstance(m.get("content"), list)
|
||||
]
|
||||
assert len(image_messages) == 1
|
||||
content_types = [part["type"] for part in image_messages[0]["content"]]
|
||||
assert "image_url" in content_types
|
||||
|
||||
|
||||
def test_agent_runner_stops_when_model_returns_no_tool_calls(demo_app: TmuxApp, out_dir: Path) -> None:
|
||||
script = [FakeMessage(content="Nothing to do here.")]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.05, max_steps=5)
|
||||
runner.run("do nothing")
|
||||
|
||||
assert runner.shots == []
|
||||
assert len(client.chat.completions.requests) == 1
|
||||
|
||||
|
||||
def test_agent_runner_keeps_capturing_via_ticker_even_if_model_refuses_outright(
|
||||
demo_app: TmuxApp, out_dir: Path
|
||||
) -> None:
|
||||
"""Reproduces a real failure: the model flatly refuses a 'gif of N minutes of X' request
|
||||
("I'm sorry, but I can't help with that.") instead of calling any tool. With --duration
|
||||
set, the background screenshot ticker must still cover the full requested wall-clock time
|
||||
regardless of the model's cooperation, so a timed GIF/video request is a mechanical
|
||||
guarantee, not something that depends on the model understanding the ask."""
|
||||
refusal = FakeMessage(content="I'm sorry, but I can't help with that.")
|
||||
client = FakeClient([refusal] * 10) # more than enough for every nudge attempt
|
||||
|
||||
runner = AgentRunner(
|
||||
demo_app,
|
||||
client,
|
||||
model="fake-model",
|
||||
out_dir=out_dir,
|
||||
delay=0.0,
|
||||
max_steps=50,
|
||||
duration=1.2,
|
||||
screenshot_interval=0.2,
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
runner.run("I want a gif of you actively using this app")
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert elapsed >= 1.0 # actually waited out roughly the full duration, not just bailed
|
||||
assert len(runner.shots) >= 3 # the ticker produced real frames despite total refusal
|
||||
for shot in runner.shots:
|
||||
assert Path(shot).exists()
|
||||
|
||||
|
||||
def test_agent_runner_survives_malformed_tool_call_instead_of_crashing(
|
||||
demo_app: TmuxApp, out_dir: Path
|
||||
) -> None:
|
||||
"""Reproduces a real crash: the model called send_keys({}) -- no 'keys' argument at all --
|
||||
which used to raise an unhandled KeyError and kill the whole process. It must instead come
|
||||
back as a tool-result error the model can read and recover from."""
|
||||
script = [
|
||||
FakeMessage(tool_calls=[FakeToolCall("call_1", "send_keys", "{}")]), # missing "keys"
|
||||
FakeMessage(tool_calls=[FakeToolCall("call_2", "send_keys", "not json")]), # malformed JSON
|
||||
FakeMessage(tool_calls=[FakeToolCall("call_3", "finish", '{"summary": "recovered"}')]),
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
|
||||
runner.run("do something") # must not raise
|
||||
|
||||
third_request_messages = client.chat.completions.requests[2]["messages"]
|
||||
tool_messages = [m for m in third_request_messages if m["role"] == "tool"]
|
||||
assert len(tool_messages) == 2
|
||||
assert "error" in tool_messages[0]["content"]
|
||||
assert "missing required argument" in tool_messages[0]["content"]
|
||||
assert "error" in tool_messages[1]["content"]
|
||||
|
||||
|
||||
def test_split_run_on_chunk_splits_bundled_numbered_items() -> None:
|
||||
bundled = (
|
||||
"1. Open vim in a new terminal and create a file. "
|
||||
"2. Use fzf to search project files and jump to a definition. "
|
||||
"3. Duplicate a heading with a macro and save."
|
||||
)
|
||||
parts = _split_run_on_chunk(bundled)
|
||||
assert len(parts) == 3
|
||||
assert parts[0].startswith("Open vim")
|
||||
assert parts[1].startswith("Use fzf")
|
||||
assert parts[2].startswith("Duplicate a heading")
|
||||
|
||||
|
||||
def test_split_run_on_chunk_leaves_ordinary_text_alone() -> None:
|
||||
assert _split_run_on_chunk("create a macro with qa and replay it with 5@a") == [
|
||||
"create a macro with qa and replay it with 5@a"
|
||||
]
|
||||
|
||||
|
||||
def test_agent_runner_normalizes_a_bundled_plan_into_separate_chunks(
|
||||
demo_app: TmuxApp, out_dir: Path, capsys
|
||||
) -> None:
|
||||
"""Reproduces a real failure: the model returned a plan with all 3 steps bundled into a
|
||||
single array element ('1. ... 2. ... 3. ...') instead of 3 separate elements as instructed.
|
||||
The runner must recover this into real separate chunks rather than executing one giant,
|
||||
unbounded mega-chunk."""
|
||||
bundled_plan_call = FakeToolCall(
|
||||
"plan_1",
|
||||
"propose_plan",
|
||||
json.dumps(
|
||||
{
|
||||
"chunks": [
|
||||
"1. Open vim and create a file. 2. Record a macro and replay it 5 times. "
|
||||
"3. Open a vertical split and copy a line across."
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
script = [
|
||||
FakeMessage(tool_calls=[bundled_plan_call]),
|
||||
FakeMessage(tool_calls=[FakeToolCall("c1", "finish", '{"summary": "opened file"}')]),
|
||||
FakeMessage(tool_calls=[FakeToolCall("c2", "finish", '{"summary": "did macro"}')]),
|
||||
FakeMessage(tool_calls=[FakeToolCall("c3", "finish", '{"summary": "did split"}')]),
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(
|
||||
demo_app,
|
||||
client,
|
||||
model="fake-model",
|
||||
out_dir=out_dir,
|
||||
delay=0.0,
|
||||
max_steps=12,
|
||||
duration=6.0,
|
||||
screenshot_interval=100.0,
|
||||
chunk_seconds=2.0,
|
||||
)
|
||||
runner.run("give a demo of advanced vim features")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "split into 3 chunks" in out
|
||||
assert "chunk 3/3 done: did split" in out
|
||||
|
||||
|
||||
def test_agent_runner_chunks_a_long_duration_session_with_status_updates(
|
||||
demo_app: TmuxApp, out_dir: Path, capsys
|
||||
) -> None:
|
||||
"""A long --duration task should be planned into chunks (like an outline before drafting
|
||||
chapters) and each chunk run with its own fresh context, printing a status line per chunk
|
||||
as it completes."""
|
||||
plan_call = FakeToolCall(
|
||||
"plan_1", "propose_plan", '{"chunks": ["show text objects", "show macros", "show marks"]}'
|
||||
)
|
||||
script = [
|
||||
FakeMessage(tool_calls=[plan_call]),
|
||||
FakeMessage(tool_calls=[FakeToolCall("c1", "finish", '{"summary": "did text objects"}')]),
|
||||
FakeMessage(tool_calls=[FakeToolCall("c2", "finish", '{"summary": "did macros"}')]),
|
||||
FakeMessage(tool_calls=[FakeToolCall("c3", "finish", '{"summary": "did marks"}')]),
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(
|
||||
demo_app,
|
||||
client,
|
||||
model="fake-model",
|
||||
out_dir=out_dir,
|
||||
delay=0.0,
|
||||
max_steps=12,
|
||||
duration=6.0,
|
||||
screenshot_interval=100.0, # long enough the ticker won't fire during this fast fake run
|
||||
chunk_seconds=2.0, # 6s / 2s -> plans for ~3 chunks
|
||||
)
|
||||
runner.run("give a demo of advanced vim features")
|
||||
|
||||
assert len(client.chat.completions.requests) == 4 # 1 planning call + 3 chunk turns
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "plan (3 chunks)" in out
|
||||
assert "chunk 1/3 done: did text objects" in out
|
||||
assert "chunk 2/3 done: did macros" in out
|
||||
assert "chunk 3/3 done: did marks" in out
|
||||
|
||||
|
||||
def test_agent_runner_retries_transient_api_errors_instead_of_crashing(
|
||||
demo_app: TmuxApp, out_dir: Path
|
||||
) -> None:
|
||||
"""Reproduces a real crash: the upstream gateway returned a transient 502
|
||||
(openai.InternalServerError) mid-session, which used to propagate straight up and kill the
|
||||
whole run. It must be retried with backoff instead."""
|
||||
import httpx2
|
||||
from openai import InternalServerError
|
||||
|
||||
request = httpx2.Request("POST", "https://example.invalid/v1/chat/completions")
|
||||
response = httpx2.Response(502, request=request, json={"detail": "Upstream connection failed"})
|
||||
transient_error = InternalServerError("Bad Gateway", response=response, body=None)
|
||||
|
||||
script = [
|
||||
transient_error,
|
||||
transient_error,
|
||||
FakeMessage(tool_calls=[FakeToolCall("call_1", "finish", '{"summary": "done after retry"}')]),
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
|
||||
runner.run("do something") # must not raise
|
||||
|
||||
assert len(client.chat.completions.requests) == 3
|
||||
|
||||
|
||||
def test_agent_runner_degrades_gracefully_on_persistent_backend_outage(
|
||||
demo_app: TmuxApp, out_dir: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Reproduces a real outage: the backend stayed down for longer than the retry budget
|
||||
(every call 502s). run() must not crash with a raw traceback -- it should stop cleanly and
|
||||
keep reporting whatever screenshots were already captured."""
|
||||
import httpx2
|
||||
from openai import InternalServerError
|
||||
|
||||
import tmux_shot.agent as agent_module
|
||||
|
||||
monkeypatch.setattr(agent_module.time, "sleep", lambda _seconds: None) # skip real backoff waits
|
||||
|
||||
request = httpx2.Request("POST", "https://example.invalid/v1/chat/completions")
|
||||
response = httpx2.Response(502, request=request, json={"detail": "Upstream connection failed"})
|
||||
always_down = InternalServerError("Bad Gateway", response=response, body=None)
|
||||
|
||||
client = FakeClient(
|
||||
[
|
||||
FakeMessage(tool_calls=[FakeToolCall("call_1", "screenshot", '{"label": "before-outage"}')]),
|
||||
always_down,
|
||||
always_down,
|
||||
always_down,
|
||||
always_down,
|
||||
]
|
||||
)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
|
||||
runner.run("do something") # must not raise
|
||||
|
||||
assert len(runner.shots) == 1 # the screenshot taken before the outage is not lost
|
||||
assert Path(runner.shots[0]).exists()
|
||||
|
||||
|
||||
def test_agent_step_strips_leaked_chat_template_tokens_from_tool_name(
|
||||
demo_app: TmuxApp, out_dir: Path
|
||||
) -> None:
|
||||
"""Reproduces a real artifact: a backend leaked raw harmony-style channel tokens into the
|
||||
function name itself ("screenshot<|channel|>commentary" instead of "screenshot"). It must
|
||||
still dispatch as the real tool on the first try, not fall through to 'unknown tool'."""
|
||||
script = [
|
||||
FakeMessage(tool_calls=[FakeToolCall("call_1", "read_text<|channel|>commentary", "{}")]),
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
|
||||
messages: list[dict] = []
|
||||
had_tools, finished, _ = runner._step(messages, "step 1")
|
||||
|
||||
tool_message = next(m for m in messages if m["role"] == "tool")
|
||||
assert "unknown tool" not in tool_message["content"]
|
||||
assert "error" not in tool_message["content"]
|
||||
|
||||
|
||||
def test_agent_runner_respects_max_steps_safety_cap(demo_app: TmuxApp, out_dir: Path) -> None:
|
||||
# every turn asks to wait, never calling finish -> must stop at max_steps, not hang forever
|
||||
script = [
|
||||
FakeMessage(tool_calls=[FakeToolCall(f"call_{i}", "wait", '{"seconds": 0.01}')]) for i in range(10)
|
||||
]
|
||||
client = FakeClient(script)
|
||||
|
||||
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=3)
|
||||
runner.run("wait forever")
|
||||
|
||||
assert len(client.chat.completions.requests) == 3
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEMO_TUI = REPO_ROOT / "examples" / "demo_tui.py"
|
||||
|
||||
|
||||
def run_cli(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "tmux_shot.cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
def test_cli_full_lifecycle(session_name: str, out_dir: Path) -> None:
|
||||
try:
|
||||
result = run_cli("new", session_name, "--command", f"python3 {DEMO_TUI}", "--width", "80", "--height", "24")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "ready" in result.stdout
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
text_result = run_cli("text", session_name)
|
||||
assert text_result.returncode == 0, text_result.stderr
|
||||
assert "tmux-shot demo TUI" in text_result.stdout
|
||||
|
||||
shot_path = out_dir / "cli_shot.png"
|
||||
shot_result = run_cli("shot", session_name, str(shot_path))
|
||||
assert shot_result.returncode == 0, shot_result.stderr
|
||||
assert shot_path.exists()
|
||||
assert shot_path.stat().st_size > 0
|
||||
|
||||
send_result = run_cli("send", session_name, "Down", "--no-enter")
|
||||
assert send_result.returncode == 0, send_result.stderr
|
||||
finally:
|
||||
run_cli("kill", session_name)
|
||||
|
||||
|
||||
def test_cli_gif_command_stitches_frames(out_dir: Path) -> None:
|
||||
from tmux_shot.render import render_ansi_to_png
|
||||
|
||||
frames = [
|
||||
render_ansi_to_png("frame a", out_dir / "a.png", cols=8, rows=1),
|
||||
render_ansi_to_png("frame b", out_dir / "b.png", cols=8, rows=1),
|
||||
]
|
||||
gif_path = out_dir / "movie.gif"
|
||||
result = run_cli("gif", *frames, str(gif_path), "--duration-ms", "150")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert gif_path.exists()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Capability probe against a real, complex third-party TUI: the `grok` CLI.
|
||||
|
||||
Everything here is strictly read-only from grok's point of view: we only ever
|
||||
send Down/Up (menu highlight movement) and never press Enter on its prompt,
|
||||
so no prompt is ever submitted to a model and no tool/agent action can run.
|
||||
Skipped entirely when `grok` isn't installed, since it's a local dev tool,
|
||||
not a project dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from tmux_shot import TmuxApp, frames_to_gif
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("grok") is None, reason="grok CLI not installed on this machine")
|
||||
|
||||
|
||||
def test_grok_doctor_screen_renders_correctly(out_dir: Path) -> None:
|
||||
session = f"tmux_shot_grok_doctor_{uuid.uuid4().hex[:8]}"
|
||||
app = TmuxApp(session, command="grok doctor", width=100, height=35)
|
||||
try:
|
||||
text = app.wait_for("issue", timeout=15)
|
||||
assert "Checks not completed" in text
|
||||
|
||||
path = app.screenshot(out_dir / "doctor.png")
|
||||
img = Image.open(path)
|
||||
assert img.width == 100 * round(img.width / 100) # sane multiple-of-cols size
|
||||
assert img.height > 0
|
||||
finally:
|
||||
try:
|
||||
app.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_grok_welcome_screen_and_menu_navigation_produce_a_movie(out_dir: Path, tmp_path: Path) -> None:
|
||||
scratch_cwd = tmp_path / "grok_scratch"
|
||||
scratch_cwd.mkdir()
|
||||
|
||||
session = f"tmux_shot_grok_welcome_{uuid.uuid4().hex[:8]}"
|
||||
app = TmuxApp(session, command=f"grok --cwd {scratch_cwd}", width=100, height=35)
|
||||
frames: list[str] = []
|
||||
try:
|
||||
app.wait_for("Grok Build", timeout=15)
|
||||
time.sleep(0.3) # let the box-drawing/logo finish painting after the text appears
|
||||
frames.append(app.screenshot(out_dir / "01_welcome.png"))
|
||||
|
||||
# Move the menu highlight down twice, screenshotting between moves. Arrow
|
||||
# keys only -- never Enter, so nothing gets submitted to grok's model.
|
||||
for i, _ in enumerate(range(2), start=2):
|
||||
app.send_keys("Down", enter=False)
|
||||
time.sleep(0.3)
|
||||
frames.append(app.screenshot(out_dir / f"{i:02d}_menu.png"))
|
||||
|
||||
assert len(frames) == 3
|
||||
for f in frames:
|
||||
assert Path(f).stat().st_size > 0
|
||||
|
||||
movie_path = frames_to_gif(frames, out_dir / "grok_welcome.gif", duration_ms=700)
|
||||
with Image.open(movie_path) as gif:
|
||||
assert gif.is_animated
|
||||
assert gif.n_frames == 3
|
||||
finally:
|
||||
try:
|
||||
app.kill()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tmux_shot.render import frames_to_gif, render_ansi_to_png
|
||||
|
||||
|
||||
def test_render_plain_text_produces_correctly_sized_image(out_dir: Path) -> None:
|
||||
path = render_ansi_to_png("hello world", out_dir / "plain.png", cols=20, rows=3, font_size=14)
|
||||
img = Image.open(path)
|
||||
assert img.size[0] > 0 and img.size[1] > 0
|
||||
# 20 cols x 3 rows of cells, image should be a whole multiple of cell size in each axis
|
||||
assert img.width % 20 == 0
|
||||
assert img.height % 3 == 0
|
||||
|
||||
|
||||
def test_render_resolves_truecolor_background_and_foreground(out_dir: Path) -> None:
|
||||
# SGR 38;2 = truecolor fg, 48;2 = truecolor bg. Pure red bg, pure green text.
|
||||
ansi = "\x1b[48;2;255;0;0m\x1b[38;2;0;255;0mX\x1b[0m"
|
||||
path = render_ansi_to_png(ansi, out_dir / "color.png", cols=1, rows=1, font_size=20)
|
||||
img = Image.open(path)
|
||||
# corner pixel is background-only (no glyph ink there)
|
||||
corner = img.getpixel((0, 0))
|
||||
assert corner == (255, 0, 0)
|
||||
|
||||
|
||||
def test_render_reverse_video_swaps_fg_and_bg(out_dir: Path) -> None:
|
||||
ansi = "\x1b[38;2;255;0;0m\x1b[48;2;0;0;255m\x1b[7mX\x1b[0m" # red fg, blue bg, reversed
|
||||
path = render_ansi_to_png(ansi, out_dir / "reverse.png", cols=1, rows=1, font_size=20)
|
||||
img = Image.open(path)
|
||||
# reversed: background should now be the (originally-foreground) red
|
||||
assert img.getpixel((0, 0)) == (255, 0, 0)
|
||||
|
||||
|
||||
def test_render_handles_ansi_16_color_names(out_dir: Path) -> None:
|
||||
ansi = "\x1b[42mX\x1b[0m" # classic SGR green background
|
||||
path = render_ansi_to_png(ansi, out_dir / "named.png", cols=1, rows=1, font_size=20)
|
||||
img = Image.open(path)
|
||||
assert img.getpixel((0, 0)) == (0, 205, 0)
|
||||
|
||||
|
||||
def test_frames_to_gif_stitches_multiple_pngs_into_one_animation(out_dir: Path) -> None:
|
||||
frame_paths = []
|
||||
for i, label in enumerate(["one", "two", "three"]):
|
||||
p = render_ansi_to_png(f"frame {label}", out_dir / f"f{i}.png", cols=10, rows=1, font_size=14)
|
||||
frame_paths.append(p)
|
||||
|
||||
gif_path = frames_to_gif(frame_paths, out_dir / "movie.gif", duration_ms=100)
|
||||
assert Path(gif_path).exists()
|
||||
|
||||
with Image.open(gif_path) as gif:
|
||||
assert gif.is_animated
|
||||
assert gif.n_frames == 3
|
||||
|
||||
|
||||
def test_frames_to_gif_rejects_empty_input(out_dir: Path) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
frames_to_gif([], out_dir / "empty.gif")
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tmux_shot import TmuxApp
|
||||
|
||||
|
||||
def test_session_is_created_and_idempotent_on_reattach(session_name: str, demo_app: TmuxApp) -> None:
|
||||
again = TmuxApp(session_name) # should attach, not recreate
|
||||
assert again.session.session_name == demo_app.session.session_name
|
||||
|
||||
|
||||
def test_capture_text_shows_initial_frame(demo_app: TmuxApp) -> None:
|
||||
time.sleep(0.3)
|
||||
text = demo_app.capture_text()
|
||||
assert "tmux-shot demo TUI" in text
|
||||
assert "@" in text
|
||||
|
||||
|
||||
def test_send_keys_multi_token_string_moves_cursor(demo_app: TmuxApp) -> None:
|
||||
"""Regression test: a single send_keys("Down Down Right") call must be split into
|
||||
separate tmux key-name arguments, not sent as one literal string (which tmux would
|
||||
silently mis-send as garbage keystrokes that don't move anything)."""
|
||||
time.sleep(0.3)
|
||||
before = demo_app.capture_text()
|
||||
|
||||
demo_app.send_keys("Down Down Right Right Right", enter=False)
|
||||
time.sleep(0.3)
|
||||
after = demo_app.capture_text()
|
||||
|
||||
assert before != after
|
||||
after_lines = [l for l in after.splitlines() if "@" in l]
|
||||
assert after_lines, "cursor marker should still be present somewhere"
|
||||
|
||||
|
||||
def test_send_keys_literal_types_text_verbatim(demo_app: TmuxApp) -> None:
|
||||
# 'q' quits the demo curses app back to the underlying shell.
|
||||
time.sleep(0.3)
|
||||
assert "tmux-shot demo TUI" in demo_app.capture_text()
|
||||
demo_app.send_keys("q", literal=True, enter=False)
|
||||
time.sleep(0.5)
|
||||
assert "tmux-shot demo TUI" not in demo_app.capture_text()
|
||||
|
||||
|
||||
def test_screenshot_produces_a_valid_sized_png(demo_app: TmuxApp, out_dir: Path) -> None:
|
||||
time.sleep(0.3)
|
||||
path = demo_app.screenshot(out_dir / "shot.png")
|
||||
img = Image.open(path)
|
||||
assert img.width > 0 and img.height > 0
|
||||
# 80 cols x 24 rows pane
|
||||
assert img.width % 80 == 0
|
||||
assert img.height % 24 == 0
|
||||
|
||||
|
||||
def test_capture_ansi_includes_escape_codes(demo_app: TmuxApp) -> None:
|
||||
time.sleep(0.3)
|
||||
ansi = demo_app.capture_ansi()
|
||||
assert "\x1b[" in ansi
|
||||
Reference in New Issue
Block a user