mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXd5qap8DWqZCS9mjh3CzH
144 lines
5.9 KiB
Python
144 lines
5.9 KiB
Python
"""The PreToolUse hook: which Bash calls run, which lease first, which stop."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from python_pkg.android_ui._elements import UiAutomationError
|
|
from python_pkg.phone_guard import hook
|
|
from python_pkg.phone_guard.tests.conftest import make_display
|
|
from python_pkg.phone_lease import NoPhoneError, PhoneBusyError
|
|
|
|
MOD = "python_pkg.phone_guard.hook"
|
|
|
|
|
|
def run(command: str, tool: str = "Bash") -> int:
|
|
return hook.main(
|
|
io.StringIO(json.dumps({"tool_name": tool, "tool_input": {"command": command}}))
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def acquire() -> MagicMock:
|
|
with (
|
|
patch(f"{MOD}.acquire") as mock,
|
|
patch(f"{MOD}.owner_id", return_value="claude:1"),
|
|
patch(f"{MOD}.resolve_serial", side_effect=lambda s=None: s or "S"),
|
|
patch(f"{MOD}.build_tool", side_effect=UiAutomationError("no sdk")),
|
|
):
|
|
yield mock
|
|
|
|
|
|
class TestMain:
|
|
def test_ignores_what_it_cannot_or_need_not_judge(self, acquire: MagicMock) -> None:
|
|
assert hook.main(io.StringIO("not json")) == 0
|
|
assert run("adb shell input tap 1 1", tool="Read") == 0
|
|
assert run("ls -la") == 0
|
|
assert hook.main(io.StringIO(json.dumps({"tool_name": "Bash"}))) == 0
|
|
acquire.assert_not_called()
|
|
|
|
def test_reads_pass_without_a_lease(self, acquire: MagicMock) -> None:
|
|
assert run("adb logcat -d") == 0
|
|
acquire.assert_not_called()
|
|
|
|
def test_a_block_exits_2_with_the_reason(
|
|
self, acquire: MagicMock, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run("adb shell input text hi") == hook.BLOCK
|
|
assert "without -d" in capsys.readouterr().err
|
|
acquire.assert_not_called()
|
|
|
|
|
|
class TestLeases:
|
|
def test_the_real_screen(self, acquire: MagicMock) -> None:
|
|
assert run("adb shell input tap 1 1") == 0
|
|
assert acquire.call_args.kwargs["scope"] == "pkg:__screen__"
|
|
assert acquire.call_args.kwargs["owner"] == "claude:1"
|
|
assert acquire.call_args.args[0] == "S"
|
|
|
|
def test_the_whole_phone_and_an_app(self, acquire: MagicMock) -> None:
|
|
assert run("adb -s X reboot && adb shell am force-stop a.b") == 0
|
|
scopes = [c.kwargs["scope"] for c in acquire.call_args_list]
|
|
assert scopes == ["whole-phone", "pkg:a.b"]
|
|
assert acquire.call_args_list[0].args[0] == "X"
|
|
|
|
def test_own_display_takes_its_app_lease(
|
|
self, acquire: MagicMock, isolated_state: Path
|
|
) -> None:
|
|
make_display(isolated_state, "S", "claude_1", "9", "dev.kuhy.todo")
|
|
assert run("adb shell input -d 9 tap 5 5") == 0
|
|
assert acquire.call_args.kwargs["scope"] == "pkg:dev.kuhy.todo"
|
|
|
|
def test_recording_own_display_is_a_read(
|
|
self, acquire: MagicMock, isolated_state: Path
|
|
) -> None:
|
|
# `phone_vd.sh record` mirrors this session's VD read-only (scrcpy
|
|
# --no-control), like `shot`: never blocked, no lease. Only the input
|
|
# that drives the recorded app takes that app's lease.
|
|
make_display(isolated_state, "S", "claude_1", "9", "dev.kuhy.todo")
|
|
assert run("~/.claude/scripts/phone_vd.sh record start out/a.mp4") == 0
|
|
assert run("~/.claude/scripts/phone_vd.sh record stop") == 0
|
|
acquire.assert_not_called()
|
|
both = "phone_vd.sh record start a.mp4 && adb shell input -d 9 swipe 1 2 3 4"
|
|
assert run(both) == 0
|
|
scopes = [call.kwargs["scope"] for call in acquire.call_args_list]
|
|
assert scopes == ["pkg:dev.kuhy.todo"]
|
|
|
|
def test_another_sessions_display_is_refused(
|
|
self,
|
|
acquire: MagicMock,
|
|
isolated_state: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
make_display(isolated_state, "S", "claude_2", "9", "x.y")
|
|
assert run("adb shell input -d 9 tap 5 5") == hook.BLOCK
|
|
assert "session claude_2's" in capsys.readouterr().err
|
|
assert run("adb shell input -d 4 tap 5 5") == hook.BLOCK
|
|
assert "not your" in capsys.readouterr().err
|
|
acquire.assert_not_called()
|
|
|
|
def test_busy_phone_blocks_naming_the_holder(
|
|
self, acquire: MagicMock, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
acquire.side_effect = PhoneBusyError("held by claude:2")
|
|
assert run("adb shell input tap 1 1") == hook.BLOCK
|
|
err = capsys.readouterr().err
|
|
assert "held by claude:2" in err
|
|
assert "own virtual display" in err
|
|
|
|
def test_no_resolvable_phone_is_left_to_adb(self, acquire: MagicMock) -> None:
|
|
with patch(f"{MOD}.resolve_serial", side_effect=NoPhoneError("several")):
|
|
assert run("adb shell input tap 1 1") == 0
|
|
acquire.assert_not_called()
|
|
|
|
|
|
class TestApkPackages:
|
|
def test_reads_the_package_with_aapt2(self, tmp_path: Path) -> None:
|
|
done = subprocess.CompletedProcess([], 0, "a.b\n", "")
|
|
with (
|
|
patch(f"{MOD}.build_tool", return_value=tmp_path / "aapt2"),
|
|
patch(f"{MOD}.subprocess.run", return_value=done) as runner,
|
|
):
|
|
assert hook._apk_packages("adb install -r app.apk other.txt") == {
|
|
"app.apk": "a.b"
|
|
}
|
|
assert runner.call_args.args[0][0] == str(tmp_path / "aapt2")
|
|
|
|
def test_failures_leave_the_apk_unknown(self, tmp_path: Path) -> None:
|
|
with patch(f"{MOD}.build_tool", return_value=tmp_path / "aapt2"):
|
|
with patch(f"{MOD}.subprocess.run", side_effect=OSError("gone")):
|
|
assert hook._apk_packages("adb install a.apk") == {}
|
|
failed = subprocess.CompletedProcess([], 1, "", "bad apk")
|
|
with patch(f"{MOD}.subprocess.run", return_value=failed):
|
|
assert hook._apk_packages("adb install a.apk") == {}
|
|
|
|
def test_no_sdk_means_no_lookup(self) -> None:
|
|
with patch(f"{MOD}.build_tool", side_effect=UiAutomationError("no sdk")):
|
|
assert hook._apk_packages("adb install a.apk") == {}
|