mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:47:14 +01:00
Drives a Flutter web build in a persistent headless chromium (systemd-run unit, own profile, CDP port) through the semantics tree: tree, tap by label, text, key, scroll, wait, shot, console. Never touches the live display; attaches only to HeadlessChrome. The MCP server wraps the same Driver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZ4sULGfyav7EEwQ6vhbfg
159 lines
5.0 KiB
Python
159 lines
5.0 KiB
Python
"""CLI: argument parsing, verb dispatch, exit codes and stderr messages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
import pytest
|
|
|
|
from flutter_web_drive import cli
|
|
from flutter_web_drive.errors import EXIT_BROWSER, EXIT_USAGE, DriveError
|
|
|
|
|
|
class Recorder:
|
|
"""Stands in for Driver: records each call and returns its name."""
|
|
|
|
instances: ClassVar[list[Recorder]] = []
|
|
|
|
def __init__(self, app: str, *, cdp_port: int | None = None) -> None:
|
|
self.app = app
|
|
self.cdp_port = cdp_port
|
|
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
|
Recorder.instances.append(self)
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
def verb(*args: Any, **kw: Any) -> str:
|
|
self.calls.append((name, args, kw))
|
|
if name == "stop":
|
|
raise DriveError("stop", "boom", EXIT_BROWSER)
|
|
return f"ok {name}"
|
|
|
|
return verb
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _recorder(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
Recorder.instances = []
|
|
monkeypatch.setattr(cli, "Driver", Recorder)
|
|
|
|
|
|
def _run(argv: list[str], stdin: str = "") -> tuple[int, Any]:
|
|
code = cli.main(argv, stdin=io.StringIO(stdin))
|
|
return code, Recorder.instances[-1].calls[-1] if Recorder.instances else None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("argv", "call"),
|
|
[
|
|
(
|
|
["start", "--url", "http://u/", "--profile", "/p", "--cdp-port", "9341"],
|
|
(
|
|
"start",
|
|
("http://u/", Path("/p"), 9341),
|
|
{"unit": None, "size": "1400x1000"},
|
|
),
|
|
),
|
|
(["shot"], ("shot", (None,), {"min_colours": 0})),
|
|
(
|
|
["shot", "o.png", "--min-colours", "2"],
|
|
("shot", (Path("o.png"),), {"min_colours": 2}),
|
|
),
|
|
(
|
|
["tree", "--filter", "x", "--all", "--row", "R"],
|
|
("tree", (), {"filt": "x", "show_all": True, "row": "R"}),
|
|
),
|
|
(["tap", "Browse", "--contains"], ("tap", ("Browse",), {"contains": True})),
|
|
(["tap-xy", "1", "2.5"], ("tap_xy", (1.0, 2.5), {})),
|
|
(["text", "hello"], ("text", ("hello",), {})),
|
|
(["key", "Enter"], ("key", ("Enter",), {})),
|
|
(["scroll", "0", "300"], ("scroll", (0.0, 300.0), {"at": None})),
|
|
(
|
|
["scroll", "0", "3", "--at", "4", "5"],
|
|
("scroll", (0.0, 3.0), {"at": (4.0, 5.0)}),
|
|
),
|
|
(
|
|
["wait", "Library", "--timeout", "3", "--gone", "--flag", "current"],
|
|
(
|
|
"wait",
|
|
("Library",),
|
|
{
|
|
"timeout": 3.0,
|
|
"contains": False,
|
|
"gone": True,
|
|
"flag": "current",
|
|
"row": None,
|
|
},
|
|
),
|
|
),
|
|
(["eval", "1+1"], ("eval", ("1+1",), {})),
|
|
(
|
|
["console", "--limit", "5", "--warnings", "--fail-on-error"],
|
|
("console", (), {"limit": 5, "warnings": True, "fail_on_error": True}),
|
|
),
|
|
],
|
|
)
|
|
def test_verbs_dispatch(
|
|
argv: list[str], call: Any, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
code, got = _run(argv)
|
|
assert code == 0
|
|
assert got == call
|
|
assert capsys.readouterr().out.startswith("ok ")
|
|
|
|
|
|
def test_global_app_and_port() -> None:
|
|
cli.main(["--app", "kartka", "--cdp-port", "9", "tree"], stdin=io.StringIO())
|
|
assert (Recorder.instances[-1].app, Recorder.instances[-1].cdp_port) == (
|
|
"kartka",
|
|
9,
|
|
)
|
|
|
|
|
|
def test_app_defaults_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("FWD_APP", "envapp")
|
|
cli.main(["tree"], stdin=io.StringIO())
|
|
assert Recorder.instances[-1].app == "envapp"
|
|
|
|
|
|
def test_text_stdin_strips_one_trailing_newline() -> None:
|
|
_, call = _run(["text", "--stdin"], stdin="p@ss\n")
|
|
assert call == ("text", ("p@ss",), {})
|
|
|
|
|
|
def test_text_file_reads_secret_without_argv(tmp_path: Path) -> None:
|
|
secret = tmp_path / "pw"
|
|
secret.write_text("s3cret\n")
|
|
_, call = _run(["text", "--file", str(secret)])
|
|
assert call == ("text", ("s3cret",), {})
|
|
|
|
|
|
def test_text_file_missing_is_usage_error(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert (
|
|
cli.main(["text", "--file", str(tmp_path / "nope")], stdin=io.StringIO())
|
|
== EXIT_USAGE
|
|
)
|
|
assert "cannot read" in capsys.readouterr().err
|
|
|
|
|
|
def test_text_needs_value_or_stdin(capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert cli.main(["text"], stdin=io.StringIO()) == EXIT_USAGE
|
|
assert "text needs a value, --stdin or --file" in capsys.readouterr().err
|
|
|
|
|
|
def test_drive_error_becomes_exit_code_and_one_line(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
code, _ = _run(["stop"])
|
|
assert code == EXIT_BROWSER
|
|
assert capsys.readouterr().err == "flutter_web_drive: stop failed: boom\n"
|
|
|
|
|
|
def test_usage_errors_exit_2() -> None:
|
|
with pytest.raises(SystemExit) as err:
|
|
cli.main(["bogus"], stdin=io.StringIO())
|
|
assert err.value.code == EXIT_USAGE
|