pantoqa-bridge 0.4.4__py3-none-any.whl → 0.4.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pantoqa_bridge/cli.py +15 -4
- pantoqa_bridge/config.py +6 -0
- pantoqa_bridge/lib/adb.py +3 -2
- pantoqa_bridge/lib/scrcpy.py +36 -24
- pantoqa_bridge/models/misc.py +6 -0
- pantoqa_bridge/routes/action.py +10 -91
- pantoqa_bridge/routes/executor.py +16 -3
- pantoqa_bridge/routes/screen_mirror.py +10 -11
- pantoqa_bridge/server.py +20 -18
- pantoqa_bridge/service/uiautomator_action.py +95 -29
- pantoqa_bridge/tasks/executor.py +16 -3
- pantoqa_bridge/utils/misc.py +65 -46
- pantoqa_bridge/utils/pkg.py +20 -12
- pantoqa_bridge/utils/process.py +46 -2
- pantoqa_bridge/utils/service_manager.py +166 -0
- {pantoqa_bridge-0.4.4.dist-info → pantoqa_bridge-0.4.20.dist-info}/METADATA +2 -2
- pantoqa_bridge-0.4.20.dist-info/RECORD +27 -0
- pantoqa_bridge-0.4.4.dist-info/RECORD +0 -25
- {pantoqa_bridge-0.4.4.dist-info → pantoqa_bridge-0.4.20.dist-info}/WHEEL +0 -0
- {pantoqa_bridge-0.4.4.dist-info → pantoqa_bridge-0.4.20.dist-info}/entry_points.txt +0 -0
- {pantoqa_bridge-0.4.4.dist-info → pantoqa_bridge-0.4.20.dist-info}/top_level.txt +0 -0
pantoqa_bridge/cli.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
3
|
from pantoqa_bridge.config import PKG_NAME, SERVER_HOST, SERVER_PORT
|
|
4
|
-
from pantoqa_bridge.server import start_bridge_server
|
|
4
|
+
from pantoqa_bridge.server import precheck_required_tools, start_bridge_server
|
|
5
5
|
from pantoqa_bridge.tasks.executor import AppiumExecutable, MaestroExecutable, QAExecutable
|
|
6
6
|
from pantoqa_bridge.utils.misc import make_sync
|
|
7
7
|
from pantoqa_bridge.utils.pkg import get_pkg_version
|
|
@@ -9,12 +9,21 @@ from pantoqa_bridge.utils.pkg import get_pkg_version
|
|
|
9
9
|
|
|
10
10
|
@click.group(help="PantoAI QA Extension CLI", invoke_without_command=True)
|
|
11
11
|
@click.option("--version", is_flag=True, help="Show version and exit")
|
|
12
|
+
@click.option(
|
|
13
|
+
"--skip-pre-check",
|
|
14
|
+
is_flag=True,
|
|
15
|
+
default=False,
|
|
16
|
+
help="Skip pre-check of required tools",
|
|
17
|
+
)
|
|
12
18
|
@click.pass_context
|
|
13
|
-
def cli(ctx: click.Context, version: bool) -> None:
|
|
19
|
+
def cli(ctx: click.Context, version: bool, skip_pre_check: bool) -> None:
|
|
14
20
|
if version:
|
|
15
21
|
click.echo(f"PantoQA Bridge version: {get_pkg_version(PKG_NAME)}")
|
|
16
22
|
ctx.exit(0)
|
|
17
23
|
|
|
24
|
+
if not skip_pre_check:
|
|
25
|
+
precheck_required_tools()
|
|
26
|
+
|
|
18
27
|
if ctx.invoked_subcommand is None:
|
|
19
28
|
ctx.invoke(serve)
|
|
20
29
|
|
|
@@ -34,19 +43,21 @@ def serve(host: str, port: int) -> None:
|
|
|
34
43
|
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
|
|
35
44
|
@click.option("--maestro-bin", help="Path to Maestro binary (for maestro framework)")
|
|
36
45
|
@click.option("--appium-url", help="Appium server URL (for appium framework)")
|
|
46
|
+
@click.option("--device", help="Device serial number to run tests on")
|
|
37
47
|
@make_sync
|
|
38
48
|
async def execute(
|
|
39
49
|
framework: str,
|
|
40
50
|
files: list[str],
|
|
41
51
|
maestro_bin: str | None = None,
|
|
42
52
|
appium_url: str | None = None,
|
|
53
|
+
device: str | None = None,
|
|
43
54
|
) -> None:
|
|
44
55
|
|
|
45
56
|
executable: QAExecutable | None = None
|
|
46
57
|
if framework.lower() == "maestro":
|
|
47
|
-
executable = MaestroExecutable(files=files, maestro_bin=maestro_bin)
|
|
58
|
+
executable = MaestroExecutable(files=files, maestro_bin=maestro_bin, device_serial=device)
|
|
48
59
|
elif framework.lower() == "appium":
|
|
49
|
-
executable = AppiumExecutable(files=files, appium_url=appium_url)
|
|
60
|
+
executable = AppiumExecutable(files=files, appium_url=appium_url, device_serial=device)
|
|
50
61
|
else:
|
|
51
62
|
raise click.ClickException(f"Unsupported framework: {framework}")
|
|
52
63
|
|
pantoqa_bridge/config.py
CHANGED
|
@@ -33,3 +33,9 @@ APPIUM_SERVER_PORT = int(os.getenv("APPIUM_SERVER_PORT") or "6566")
|
|
|
33
33
|
APPIUM_SERVER_URL = f"http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}"
|
|
34
34
|
|
|
35
35
|
MAESTRO_BIN = "maestro"
|
|
36
|
+
INACTIVITY_TIMEOUT = 5 * 60
|
|
37
|
+
|
|
38
|
+
APPIUM_BIN = os.getenv("APPIUM_BIN") or "appium"
|
|
39
|
+
NODE_PATH = os.getenv("NODE_PATH") or "node"
|
|
40
|
+
# ADBUTILS_ADB_PATH is also used by https://github.com/openatx/adbutils package
|
|
41
|
+
ADB_BIN = os.getenv("ADBUTILS_ADB_PATH") or "adb"
|
pantoqa_bridge/lib/adb.py
CHANGED
|
@@ -3,7 +3,8 @@ import subprocess
|
|
|
3
3
|
from asyncio.subprocess import Process
|
|
4
4
|
from typing import Literal, overload
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
from pantoqa_bridge.config import ADB_BIN
|
|
7
|
+
from pantoqa_bridge.logger import logger
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class ADB:
|
|
@@ -89,7 +90,7 @@ class ADB:
|
|
|
89
90
|
else:
|
|
90
91
|
cmds = [self.adb] + cmd
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
logger.info(f"Executing ADB command: {' '.join(cmds)}")
|
|
93
94
|
|
|
94
95
|
process = await asyncio.create_subprocess_exec(
|
|
95
96
|
*cmds,
|
pantoqa_bridge/lib/scrcpy.py
CHANGED
|
@@ -6,8 +6,7 @@ from collections.abc import Awaitable, Callable
|
|
|
6
6
|
|
|
7
7
|
from pantoqa_bridge.config import SCRCPY_SERVER_BIN, SCRCPY_SERVER_VERSION, SCRCPY_SOCKET_PORT
|
|
8
8
|
from pantoqa_bridge.lib.adb import ADB
|
|
9
|
-
|
|
10
|
-
# ADB = "adb"
|
|
9
|
+
from pantoqa_bridge.logger import logger
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class InvalidDummyByte(Exception):
|
|
@@ -34,7 +33,7 @@ class ScrcpyPyClient:
|
|
|
34
33
|
self.server_version = server_version
|
|
35
34
|
self.max_size = max_size or 720
|
|
36
35
|
self._is_stopping = asyncio.Event()
|
|
37
|
-
self._remote_tmp_path = f"/data/local/tmp/scrcpy-server{server_version}"
|
|
36
|
+
self._remote_tmp_path = f"/data/local/tmp/scrcpy-server{self.server_version}"
|
|
38
37
|
self._scrcpy_process: Process | None = None
|
|
39
38
|
|
|
40
39
|
async def push_server(self):
|
|
@@ -57,8 +56,8 @@ class ScrcpyPyClient:
|
|
|
57
56
|
self._scrcpy_process = process
|
|
58
57
|
assert process.stdout, "Scrcpy process stdout is None."
|
|
59
58
|
data = await process.stdout.readline() # some delay until server starts
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
logger.info(f"[Scrcpy]: {data!r}")
|
|
60
|
+
logger.info("Scrcpy server started.")
|
|
62
61
|
|
|
63
62
|
async def forward_video_socket(self):
|
|
64
63
|
cmd = ["forward", f"tcp:{self.socket_port}", "localabstract:scrcpy"]
|
|
@@ -67,14 +66,14 @@ class ScrcpyPyClient:
|
|
|
67
66
|
async def read_video_socket(self, connect_timeout: int = 30):
|
|
68
67
|
|
|
69
68
|
async def connect() -> socket.socket:
|
|
70
|
-
|
|
69
|
+
logger.info("Trying to connect to video socket...")
|
|
71
70
|
sock = socket.create_connection(("127.0.0.1", self.socket_port), timeout=5)
|
|
72
71
|
sock.setblocking(False)
|
|
73
72
|
loop = asyncio.get_running_loop()
|
|
74
73
|
DUMMY_FIELD_LENGTH = 1
|
|
75
74
|
dummy_byte = await loop.sock_recv(sock, DUMMY_FIELD_LENGTH)
|
|
76
75
|
if not len(dummy_byte) or dummy_byte != b"\x00":
|
|
77
|
-
|
|
76
|
+
logger.info(f"Received invalid dummy byte: {dummy_byte!r}")
|
|
78
77
|
sock.close()
|
|
79
78
|
raise InvalidDummyByte("invalid_dummy_byte")
|
|
80
79
|
|
|
@@ -83,37 +82,50 @@ class ScrcpyPyClient:
|
|
|
83
82
|
async def connect_with_retries(connect_timeout: int = 30) -> socket.socket:
|
|
84
83
|
start_time = asyncio.get_running_loop().time()
|
|
85
84
|
while True:
|
|
86
|
-
|
|
85
|
+
logger.info("Attempting to connect to scrcpy video socket...")
|
|
87
86
|
try:
|
|
88
87
|
sock = await connect()
|
|
89
88
|
return sock
|
|
90
89
|
except InvalidDummyByte as e:
|
|
91
90
|
if asyncio.get_running_loop().time() - start_time > connect_timeout:
|
|
92
91
|
raise TimeoutError("Timeout connecting to scrcpy video socket.")
|
|
93
|
-
|
|
92
|
+
logger.info(f"Connection to video socket failed: {e}. Retrying...")
|
|
94
93
|
await asyncio.sleep(1)
|
|
95
94
|
|
|
96
95
|
sock = await connect_with_retries(connect_timeout)
|
|
97
|
-
|
|
96
|
+
logger.info("Connected to video socket. Streaming video data...")
|
|
98
97
|
|
|
99
98
|
loop = asyncio.get_running_loop()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
try:
|
|
100
|
+
while not self._is_stopping.is_set():
|
|
101
|
+
try:
|
|
102
|
+
raw_data = await loop.sock_recv(sock, 0x10000)
|
|
103
|
+
if not raw_data:
|
|
104
|
+
logger.info("No more data from video stream. Exiting...")
|
|
105
|
+
break
|
|
106
|
+
await self.on_frame(raw_data)
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
logger.info("Video socket read task cancelled.")
|
|
105
109
|
break
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.info(f"Error reading video stream: {e}")
|
|
112
|
+
break
|
|
113
|
+
finally:
|
|
114
|
+
logger.info("Closing video socket...")
|
|
115
|
+
try:
|
|
116
|
+
sock.close()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
110
119
|
|
|
111
120
|
async def stop(self):
|
|
112
121
|
self._is_stopping.set()
|
|
113
122
|
cmd = ["forward", "--remove", f"tcp:{self.socket_port}"]
|
|
114
123
|
await self.adb.exec(cmd, skip_check=True)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
logger.info("Stopped scrcpy client.")
|
|
125
|
+
try:
|
|
126
|
+
if self._scrcpy_process:
|
|
127
|
+
logger.info("Terminating scrcpy server process...")
|
|
128
|
+
self._scrcpy_process.send_signal(signal.SIGTERM)
|
|
129
|
+
await self._scrcpy_process.wait()
|
|
130
|
+
except ProcessLookupError:
|
|
131
|
+
pass
|
pantoqa_bridge/routes/action.py
CHANGED
|
@@ -3,16 +3,12 @@ from typing import Any
|
|
|
3
3
|
from fastapi import APIRouter
|
|
4
4
|
from pydantic import BaseModel
|
|
5
5
|
|
|
6
|
-
from pantoqa_bridge.
|
|
6
|
+
from pantoqa_bridge.models.misc import Action
|
|
7
|
+
from pantoqa_bridge.utils.service_manager import get_service
|
|
7
8
|
|
|
8
9
|
router = APIRouter()
|
|
9
10
|
|
|
10
11
|
|
|
11
|
-
class Action(BaseModel):
|
|
12
|
-
action_type: str
|
|
13
|
-
params: dict | None = None
|
|
14
|
-
|
|
15
|
-
|
|
16
12
|
class ActionRequestModel(BaseModel):
|
|
17
13
|
id: str
|
|
18
14
|
action: Action
|
|
@@ -27,88 +23,11 @@ class ActionResponseModel(BaseModel):
|
|
|
27
23
|
|
|
28
24
|
@router.post('/perform-action', response_model=ActionResponseModel)
|
|
29
25
|
async def perform_action(request: ActionRequestModel):
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
action_type = action.action_type
|
|
39
|
-
if action_type == "screenshot":
|
|
40
|
-
return srv.take_screenshot()
|
|
41
|
-
if action_type == "click":
|
|
42
|
-
params = action.params
|
|
43
|
-
assert params, "params is not present."
|
|
44
|
-
srv.click(params['x'], params['y'])
|
|
45
|
-
return
|
|
46
|
-
if action_type == "long_click":
|
|
47
|
-
params = action.params
|
|
48
|
-
assert params, "params is not present."
|
|
49
|
-
srv.long_click(params['x'], params['y'])
|
|
50
|
-
return
|
|
51
|
-
if action_type == "get_os_version":
|
|
52
|
-
return srv.get_os_version()
|
|
53
|
-
if action_type == "get_device_model":
|
|
54
|
-
return srv.get_device_model()
|
|
55
|
-
if action_type == "get_device_resolution":
|
|
56
|
-
return srv.device_resolution()
|
|
57
|
-
if action_type == "get_ui_dump":
|
|
58
|
-
return srv.get_ui_dump()
|
|
59
|
-
if action_type == "get_current_app_activity":
|
|
60
|
-
return srv.get_current_app_activity()
|
|
61
|
-
if action_type == "get_main_activity":
|
|
62
|
-
params = action.params
|
|
63
|
-
assert params, "params is not present."
|
|
64
|
-
return srv.get_main_activity(params['package_name'])
|
|
65
|
-
if action_type == "get_all_packages":
|
|
66
|
-
return srv.get_all_packages()
|
|
67
|
-
if action_type == "open_app":
|
|
68
|
-
params = action.params
|
|
69
|
-
assert params, "params is not present."
|
|
70
|
-
srv.open_app(params['package_name'], params['activity_name'])
|
|
71
|
-
return
|
|
72
|
-
if action_type == "close_keyboard":
|
|
73
|
-
srv.close_keyboard()
|
|
74
|
-
return
|
|
75
|
-
if action_type == "press_enter":
|
|
76
|
-
srv.press_enter()
|
|
77
|
-
return
|
|
78
|
-
if action_type == "go_back":
|
|
79
|
-
srv.go_back()
|
|
80
|
-
return
|
|
81
|
-
if action_type == "is_keyboard_open":
|
|
82
|
-
return srv.is_keyboard_open()
|
|
83
|
-
if action_type == "goto_home":
|
|
84
|
-
srv.goto_home()
|
|
85
|
-
return
|
|
86
|
-
if action_type == "clear_all_inputs":
|
|
87
|
-
srv.clear_all_inputs()
|
|
88
|
-
return
|
|
89
|
-
if action_type == "backspace":
|
|
90
|
-
srv.backspace()
|
|
91
|
-
return
|
|
92
|
-
if action_type == "input_text":
|
|
93
|
-
params = action.params
|
|
94
|
-
assert params, "params is not present."
|
|
95
|
-
srv.input_text(params['text'])
|
|
96
|
-
return
|
|
97
|
-
if action_type == "swipe":
|
|
98
|
-
params = action.params
|
|
99
|
-
assert params, "params is not present."
|
|
100
|
-
srv.swipe(params['x1'], params['y1'], params['x2'], params['y2'])
|
|
101
|
-
return
|
|
102
|
-
if action_type == "long_press":
|
|
103
|
-
params = action.params
|
|
104
|
-
assert params, "params is not present."
|
|
105
|
-
srv.long_press(params['x'], params['y'])
|
|
106
|
-
return
|
|
107
|
-
if action_type == "get_oem_name":
|
|
108
|
-
return srv.get_oem_name()
|
|
109
|
-
if action_type == "get_device_name":
|
|
110
|
-
return srv.get_device_name()
|
|
111
|
-
if action_type == "get_os_build_version":
|
|
112
|
-
return srv.get_os_build_version()
|
|
113
|
-
|
|
114
|
-
raise ValueError(f"Unsupported action type: {action_type}")
|
|
26
|
+
srv = get_service(request.device_serial_no)
|
|
27
|
+
result = srv.process(request.action)
|
|
28
|
+
|
|
29
|
+
return ActionResponseModel(
|
|
30
|
+
id=request.id,
|
|
31
|
+
type="driver_action_response",
|
|
32
|
+
data=result,
|
|
33
|
+
)
|
|
@@ -13,7 +13,9 @@ from pydantic import BaseModel
|
|
|
13
13
|
from pantoqa_bridge.config import PKG_NAME
|
|
14
14
|
from pantoqa_bridge.logger import logger
|
|
15
15
|
from pantoqa_bridge.models.code_execute import CodeFile
|
|
16
|
-
from pantoqa_bridge.utils.process import create_stream_pipe,
|
|
16
|
+
from pantoqa_bridge.utils.process import (create_stream_pipe, kill_process_gracefully,
|
|
17
|
+
stream_process)
|
|
18
|
+
from pantoqa_bridge.utils.service_manager import remove_all_services
|
|
17
19
|
|
|
18
20
|
route = APIRouter()
|
|
19
21
|
|
|
@@ -21,6 +23,7 @@ route = APIRouter()
|
|
|
21
23
|
class ExecutionRequest(BaseModel):
|
|
22
24
|
files: list[CodeFile]
|
|
23
25
|
framework: Literal['APPIUM', 'MAESTRO']
|
|
26
|
+
device_serial: str | None = None
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
class ExecutionResult(BaseModel):
|
|
@@ -52,6 +55,11 @@ async def execute(rawrequest: Request) -> StreamingResponse:
|
|
|
52
55
|
return StreamingResponse(stream, media_type="text/event-stream")
|
|
53
56
|
|
|
54
57
|
|
|
58
|
+
@route.delete("/stop-test/{process_id}")
|
|
59
|
+
async def stop_test(process_id: str):
|
|
60
|
+
kill_process_gracefully(int(process_id))
|
|
61
|
+
|
|
62
|
+
|
|
55
63
|
def _format_sse(event: str, data: str) -> str:
|
|
56
64
|
json_dict = {
|
|
57
65
|
"event": event,
|
|
@@ -72,23 +80,27 @@ async def _execute_qacode(
|
|
|
72
80
|
for code_file in request.files:
|
|
73
81
|
target = workdir / code_file.path
|
|
74
82
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
-
target.write_text(code_file.content)
|
|
83
|
+
target.write_text(code_file.content, encoding="utf-8")
|
|
76
84
|
copiedfiles.append(str(target))
|
|
77
85
|
|
|
78
86
|
await on_data("status", "Starting testing...")
|
|
79
87
|
|
|
80
88
|
process: asyncio.subprocess.Process | None = None
|
|
81
89
|
try:
|
|
90
|
+
remove_all_services() # kill all the LocalBridge instance and automator connection.
|
|
82
91
|
cmd = [
|
|
83
92
|
sys.executable,
|
|
84
93
|
"-u", # unbuffered output
|
|
85
94
|
"-m",
|
|
86
95
|
PKG_NAME,
|
|
96
|
+
"--skip-pre-check",
|
|
87
97
|
"execute",
|
|
88
98
|
"--framework",
|
|
89
99
|
request.framework.lower(),
|
|
90
|
-
*copiedfiles,
|
|
91
100
|
]
|
|
101
|
+
if request.device_serial:
|
|
102
|
+
cmd.extend(["--device", request.device_serial])
|
|
103
|
+
cmd.extend(copiedfiles)
|
|
92
104
|
process = await asyncio.create_subprocess_exec(
|
|
93
105
|
*cmd,
|
|
94
106
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -98,6 +110,7 @@ async def _execute_qacode(
|
|
|
98
110
|
# env={**os.environ.copy(), "PYTHONUNBUFFERED": "1"},
|
|
99
111
|
)
|
|
100
112
|
logger.info(f"Started execution process with PID: {process.pid}")
|
|
113
|
+
await on_data("pid", str(process.pid))
|
|
101
114
|
await stream_process(process, on_data)
|
|
102
115
|
exit_code = process.returncode
|
|
103
116
|
except asyncio.CancelledError:
|
|
@@ -3,6 +3,7 @@ import asyncio
|
|
|
3
3
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
4
4
|
|
|
5
5
|
from pantoqa_bridge.lib.scrcpy import ScrcpyPyClient
|
|
6
|
+
from pantoqa_bridge.logger import logger
|
|
6
7
|
|
|
7
8
|
route = APIRouter()
|
|
8
9
|
|
|
@@ -14,16 +15,14 @@ async def ws_endpoint(ws: WebSocket):
|
|
|
14
15
|
try:
|
|
15
16
|
params = ws.query_params
|
|
16
17
|
max_size = int(params["max_size"]) if params.get("max_size") else None
|
|
17
|
-
params
|
|
18
|
-
streaming = ScreenMirrorOverWS(ws, max_size)
|
|
18
|
+
device_serial = params.get("device_serial") if params.get("device_serial") else None
|
|
19
|
+
streaming = ScreenMirrorOverWS(ws, max_size, device_serial)
|
|
19
20
|
await streaming.stream() # blocking call
|
|
20
|
-
await streaming.teardown()
|
|
21
21
|
except WebSocketDisconnect:
|
|
22
|
-
|
|
23
|
-
if streaming:
|
|
24
|
-
await streaming.teardown()
|
|
22
|
+
logger.warning("WebSocket disconnected.")
|
|
25
23
|
except Exception as e:
|
|
26
|
-
|
|
24
|
+
logger.exception(f"Error in WebSocket endpoint: {e}")
|
|
25
|
+
finally:
|
|
27
26
|
if streaming:
|
|
28
27
|
await streaming.teardown()
|
|
29
28
|
|
|
@@ -49,7 +48,7 @@ class ScreenMirrorOverWS:
|
|
|
49
48
|
await self.scrcpy.forward_video_socket()
|
|
50
49
|
await self.scrcpy.start_server()
|
|
51
50
|
self._socket_read_task = asyncio.create_task(self.scrcpy.read_video_socket())
|
|
52
|
-
|
|
51
|
+
logger.info("Started streaming screen over WebSocket.")
|
|
53
52
|
await self._socket_read_task # Blocking call to keep streaming
|
|
54
53
|
|
|
55
54
|
async def on_frame_update(self, frame: bytes):
|
|
@@ -59,17 +58,17 @@ class ScreenMirrorOverWS:
|
|
|
59
58
|
self._is_streaming = False
|
|
60
59
|
|
|
61
60
|
if self.scrcpy:
|
|
62
|
-
|
|
61
|
+
logger.info("[teardown]Stopping scrcpy client...")
|
|
63
62
|
try:
|
|
64
63
|
await self.scrcpy.stop()
|
|
65
64
|
finally:
|
|
66
65
|
self.scrcpy = None
|
|
67
66
|
|
|
68
67
|
if self._socket_read_task and not self._socket_read_task.done():
|
|
69
|
-
|
|
68
|
+
logger.info("[teardown]Cancelling socket read task...")
|
|
70
69
|
try:
|
|
71
70
|
self._socket_read_task.cancel()
|
|
72
71
|
finally:
|
|
73
72
|
self._socket_read_task = None
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
logger.info("[teardown]Teardown complete.")
|
pantoqa_bridge/server.py
CHANGED
|
@@ -7,7 +7,7 @@ import uvicorn
|
|
|
7
7
|
from fastapi import FastAPI
|
|
8
8
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
9
|
|
|
10
|
-
from pantoqa_bridge.config import (APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, PKG_NAME,
|
|
10
|
+
from pantoqa_bridge.config import (APPIUM_BIN, APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, PKG_NAME,
|
|
11
11
|
SCRCPY_SERVER_BIN, SERVER_HOST, SERVER_PORT)
|
|
12
12
|
from pantoqa_bridge.logger import logger
|
|
13
13
|
from pantoqa_bridge.routes.action import router as action_router
|
|
@@ -15,28 +15,30 @@ from pantoqa_bridge.routes.adb import router as adb_router
|
|
|
15
15
|
from pantoqa_bridge.routes.executor import route as executor_route
|
|
16
16
|
from pantoqa_bridge.routes.misc import route as misc_route
|
|
17
17
|
from pantoqa_bridge.routes.screen_mirror import route as screen_mirror_route
|
|
18
|
-
from pantoqa_bridge.utils.misc import ensure_android_home,
|
|
18
|
+
from pantoqa_bridge.utils.misc import ensure_android_home, start_appium_process_in_bg
|
|
19
19
|
from pantoqa_bridge.utils.pkg import get_latest_package_version, get_pkg_version, upgrade_package
|
|
20
|
-
from pantoqa_bridge.utils.process import (
|
|
21
|
-
wait_for_port_to_alive
|
|
20
|
+
from pantoqa_bridge.utils.process import (kill_process_by_port, kill_self_process,
|
|
21
|
+
wait_for_port_to_alive)
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def create_app() -> FastAPI:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
|
|
26
|
+
def on_exit(pid: int, returncode: int) -> None:
|
|
27
|
+
if returncode == 0:
|
|
28
|
+
logger.info(f"Appium process exited normally PID={pid}.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
logger.info(f"Appium process exited PID={pid}. Return code: {returncode}. ")
|
|
32
|
+
logger.info("Killing bridge server...")
|
|
33
|
+
kill_self_process()
|
|
34
|
+
|
|
35
|
+
start_appium_process_in_bg(on_exit=on_exit)
|
|
28
36
|
|
|
29
37
|
@asynccontextmanager
|
|
30
38
|
async def lifespan(app: FastAPI):
|
|
31
39
|
await wait_for_port_to_alive(APPIUM_SERVER_PORT, APPIUM_SERVER_HOST, timeout=15)
|
|
32
|
-
|
|
33
|
-
def on_exit(pid: int) -> None:
|
|
34
|
-
logger.info("Appium process exite. Shutting down PantoQA Bridge server...")
|
|
35
|
-
kill_self_process()
|
|
36
|
-
|
|
37
|
-
watch_process_bg(appium_pid, on_exit=on_exit)
|
|
38
40
|
yield
|
|
39
|
-
|
|
41
|
+
kill_process_by_port(APPIUM_SERVER_PORT, timeout=5)
|
|
40
42
|
|
|
41
43
|
app = FastAPI(
|
|
42
44
|
title="PantoAI QA Ext",
|
|
@@ -79,7 +81,7 @@ class DependencyNotInstalledError(Exception):
|
|
|
79
81
|
pass
|
|
80
82
|
|
|
81
83
|
|
|
82
|
-
def
|
|
84
|
+
def precheck_required_tools():
|
|
83
85
|
|
|
84
86
|
def _current_package_check():
|
|
85
87
|
curent_pkg_version = get_pkg_version(PKG_NAME)
|
|
@@ -95,7 +97,7 @@ def _pre_check_required_tools():
|
|
|
95
97
|
|
|
96
98
|
def _install_uiautomator2() -> None:
|
|
97
99
|
subprocess.check_output(
|
|
98
|
-
[
|
|
100
|
+
[APPIUM_BIN, "driver", "install", "uiautomator2"],
|
|
99
101
|
stderr=subprocess.STDOUT,
|
|
100
102
|
text=True,
|
|
101
103
|
)
|
|
@@ -112,7 +114,7 @@ def _pre_check_required_tools():
|
|
|
112
114
|
required_tools = [
|
|
113
115
|
("node", "node --version", "Node.js", None),
|
|
114
116
|
("npm", "npm --version", "npm", None),
|
|
115
|
-
(
|
|
117
|
+
(APPIUM_BIN, f"{APPIUM_BIN} --version", "Appium", _install_appium),
|
|
116
118
|
]
|
|
117
119
|
for cmd, version_cmd, name, install_func in required_tools:
|
|
118
120
|
if shutil.which(cmd) is None:
|
|
@@ -130,7 +132,7 @@ def _pre_check_required_tools():
|
|
|
130
132
|
|
|
131
133
|
# Check if uiautomator2 server is installed
|
|
132
134
|
uiautomator2_check = subprocess.check_output(
|
|
133
|
-
"
|
|
135
|
+
f"{APPIUM_BIN} driver list --installed --json",
|
|
134
136
|
shell=True,
|
|
135
137
|
text=True,
|
|
136
138
|
)
|
|
@@ -1,45 +1,89 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import re
|
|
2
3
|
from io import BytesIO
|
|
3
4
|
|
|
4
5
|
import uiautomator2 as u2 # type: ignore
|
|
5
6
|
from adbutils import adb # type: ignore
|
|
6
7
|
|
|
8
|
+
from pantoqa_bridge.logger import logger
|
|
9
|
+
|
|
7
10
|
|
|
8
11
|
class LocalBridgeService():
|
|
9
12
|
|
|
10
|
-
def __init__(self, device_serial: str
|
|
11
|
-
self.uiautomator =
|
|
13
|
+
def __init__(self, device_serial: str):
|
|
14
|
+
self.uiautomator: u2.Device | None = None
|
|
12
15
|
self.adb = adb.device(device_serial)
|
|
16
|
+
self._device_resolution: dict | None = None
|
|
17
|
+
|
|
18
|
+
def __enter__(self):
|
|
19
|
+
# self.uiautomator.start_uiautomator()
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(self, exc_type, exc, tb):
|
|
23
|
+
self.stop()
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
if self.uiautomator:
|
|
28
|
+
self.uiautomator.stop_uiautomator()
|
|
29
|
+
self.uiautomator = None
|
|
30
|
+
...
|
|
13
31
|
|
|
14
32
|
def take_screenshot(self) -> str:
|
|
15
|
-
image = self.
|
|
33
|
+
image = self.adb.screenshot()
|
|
16
34
|
buf = BytesIO()
|
|
17
35
|
image.save(buf, format="PNG")
|
|
18
36
|
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
|
19
37
|
|
|
20
38
|
def click(self, x: int, y: int):
|
|
21
|
-
self.
|
|
39
|
+
self.adb.click(x, y)
|
|
22
40
|
|
|
23
41
|
def long_click(self, x: int, y: int):
|
|
24
|
-
|
|
42
|
+
duration = 100
|
|
43
|
+
self.adb.swipe(x, y, x, y, duration=duration)
|
|
25
44
|
|
|
26
45
|
def get_os_version(self) -> str:
|
|
27
|
-
|
|
46
|
+
version = self.adb.shell("getprop ro.build.version.release")
|
|
47
|
+
return f"Android {version.strip()}" if version else "unknown"
|
|
28
48
|
|
|
29
49
|
def get_device_model(self) -> str:
|
|
30
|
-
|
|
50
|
+
result = self.adb.shell("getprop ro.product.model")
|
|
51
|
+
return result.strip()
|
|
31
52
|
|
|
32
53
|
def device_resolution(self) -> dict:
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
if self._device_resolution:
|
|
55
|
+
return self._device_resolution
|
|
56
|
+
txt_result = self.adb.shell("wm size")
|
|
57
|
+
|
|
58
|
+
txt_result = txt_result.replace("Physical size:", "").strip()
|
|
59
|
+
|
|
60
|
+
match = re.search(r'(\d+)\s*x\s*(\d+)', txt_result)
|
|
61
|
+
if not match:
|
|
62
|
+
raise ValueError(f"Could not parse resolution from: {txt_result!r}")
|
|
63
|
+
|
|
64
|
+
width, height = map(int, match.groups())
|
|
65
|
+
logger.debug(f"Device resolution: {width}x{height}")
|
|
66
|
+
self._device_resolution = {"width": width, "height": height}
|
|
67
|
+
return self._device_resolution
|
|
35
68
|
|
|
36
69
|
def get_ui_dump(self) -> str:
|
|
70
|
+
try:
|
|
71
|
+
xml = self.adb.dump_hierarchy()
|
|
72
|
+
return xml
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
if not self.uiautomator:
|
|
77
|
+
self.uiautomator = u2.connect_adb(self.adb.serial)
|
|
78
|
+
|
|
37
79
|
xml = self.uiautomator.dump_hierarchy()
|
|
38
80
|
return xml
|
|
39
81
|
|
|
40
82
|
def get_current_app_activity(self) -> str:
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
data = self.adb.app_current()
|
|
84
|
+
package = data.package
|
|
85
|
+
activity = data.activity
|
|
86
|
+
return f"{package}/{activity}"
|
|
43
87
|
|
|
44
88
|
def get_main_activity(self, package_name: str) -> str | None:
|
|
45
89
|
result = self.adb.shell(f"cmd package resolve-activity --brief {package_name}")
|
|
@@ -51,52 +95,74 @@ class LocalBridgeService():
|
|
|
51
95
|
return None
|
|
52
96
|
|
|
53
97
|
def get_all_packages(self) -> list[str]:
|
|
54
|
-
packages = self.
|
|
98
|
+
packages = self.adb.list_packages()
|
|
55
99
|
return packages
|
|
56
100
|
|
|
57
101
|
def open_app(self, package_name: str, activity_name: str = ".MainActivity"):
|
|
58
|
-
self.
|
|
102
|
+
self.adb.app_start(package_name, activity_name)
|
|
59
103
|
|
|
60
104
|
def close_keyboard(self):
|
|
61
|
-
self.
|
|
105
|
+
self.go_back()
|
|
62
106
|
|
|
63
107
|
def press_enter(self):
|
|
64
|
-
self.
|
|
108
|
+
self.adb.shell("input keyevent 66")
|
|
65
109
|
|
|
66
110
|
def go_back(self):
|
|
67
|
-
self.
|
|
111
|
+
self.adb.shell("input keyevent 4")
|
|
68
112
|
|
|
69
113
|
def is_keyboard_open(self) -> bool:
|
|
70
114
|
result = self.adb.shell("dumpsys input_method")
|
|
71
115
|
return "mInputShown=true" in result
|
|
72
116
|
|
|
73
117
|
def goto_home(self):
|
|
74
|
-
self.
|
|
118
|
+
self.adb.shell("input keyevent 3")
|
|
75
119
|
|
|
76
120
|
def clear_all_inputs(self):
|
|
77
|
-
self.
|
|
121
|
+
self.adb.shell("input keycombination 113 29") # select all (ctrl + a)
|
|
122
|
+
self.backspace()
|
|
78
123
|
|
|
79
124
|
def backspace(self):
|
|
80
|
-
self.
|
|
125
|
+
self.adb.shell("input keyevent 67")
|
|
81
126
|
|
|
82
127
|
def input_text(self, text: str):
|
|
83
|
-
|
|
128
|
+
text = _adb_encode(text)
|
|
129
|
+
self.adb.shell(f'input text "{text}"')
|
|
84
130
|
|
|
85
131
|
def swipe(self, x1: int, y1: int, x2: int, y2: int):
|
|
86
|
-
self.
|
|
132
|
+
self.adb.swipe(x1, y1, x2, y2)
|
|
87
133
|
|
|
88
134
|
def long_press(self, x: int, y: int):
|
|
89
|
-
|
|
135
|
+
duration = 100
|
|
136
|
+
self.adb.swipe(x, y, x, y, duration=duration)
|
|
90
137
|
|
|
91
138
|
def get_oem_name(self) -> str:
|
|
92
|
-
|
|
93
|
-
return
|
|
139
|
+
result = self.adb.shell("getprop ro.product.manufacturer")
|
|
140
|
+
return result.strip()
|
|
94
141
|
|
|
95
142
|
def get_device_name(self) -> str:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return info["model"]
|
|
143
|
+
result = self.adb.shell("getprop ro.product.model")
|
|
144
|
+
return result.strip()
|
|
99
145
|
|
|
100
146
|
def get_os_build_version(self) -> str:
|
|
101
|
-
|
|
102
|
-
return
|
|
147
|
+
result = self.adb.shell("getprop ro.build.display.id")
|
|
148
|
+
return result.strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _adb_encode(text: str) -> str:
|
|
152
|
+
escape_map = {
|
|
153
|
+
'%': r'\%',
|
|
154
|
+
' ': '%s',
|
|
155
|
+
"'": r"\'",
|
|
156
|
+
'"': r'\"',
|
|
157
|
+
'&': r'\&',
|
|
158
|
+
'(': r'\(',
|
|
159
|
+
')': r'\)',
|
|
160
|
+
';': r'\;',
|
|
161
|
+
'<': r'\<',
|
|
162
|
+
'>': r'\>',
|
|
163
|
+
'\\': r'\\',
|
|
164
|
+
}
|
|
165
|
+
encoded = ""
|
|
166
|
+
for ch in text:
|
|
167
|
+
encoded += escape_map.get(ch, ch)
|
|
168
|
+
return encoded
|
pantoqa_bridge/tasks/executor.py
CHANGED
|
@@ -25,16 +25,23 @@ class QAExecutable(abc.ABC):
|
|
|
25
25
|
|
|
26
26
|
class MaestroExecutable(QAExecutable):
|
|
27
27
|
|
|
28
|
-
def __init__(self,
|
|
28
|
+
def __init__(self,
|
|
29
|
+
files: list[str],
|
|
30
|
+
maestro_bin: str | None = None,
|
|
31
|
+
device_serial: str | None = None):
|
|
29
32
|
super().__init__(files)
|
|
30
33
|
self.maestro_bin = maestro_bin or MAESTRO_BIN
|
|
34
|
+
self.device_serial = device_serial
|
|
31
35
|
|
|
32
36
|
async def execute(self):
|
|
33
37
|
yml_files = [f for f in self.files if (f.endswith('.yml') or f.endswith('.yaml'))]
|
|
34
38
|
if not yml_files:
|
|
35
39
|
logger.error("No Maestro test files found.")
|
|
36
40
|
return
|
|
37
|
-
cmd = [self.maestro_bin, "test"]
|
|
41
|
+
cmd = [self.maestro_bin, "test"]
|
|
42
|
+
if self.device_serial:
|
|
43
|
+
cmd.extend(["--device", self.device_serial])
|
|
44
|
+
cmd.extend(yml_files)
|
|
38
45
|
logger.debug(f"Maestro command: {' '.join(cmd)}")
|
|
39
46
|
|
|
40
47
|
logger.info(f"Running Maestro: {' '.join(cmd)}")
|
|
@@ -56,14 +63,20 @@ class MaestroExecutable(QAExecutable):
|
|
|
56
63
|
|
|
57
64
|
class AppiumExecutable(QAExecutable):
|
|
58
65
|
|
|
59
|
-
def __init__(self,
|
|
66
|
+
def __init__(self,
|
|
67
|
+
files: list[str],
|
|
68
|
+
appium_url: str | None = None,
|
|
69
|
+
device_serial: str | None = None):
|
|
60
70
|
super().__init__(files)
|
|
61
71
|
options = UiAutomator2Options()
|
|
62
72
|
options.set_capability("platformName", "Android")
|
|
63
73
|
options.set_capability("automationName", "UiAutomator2")
|
|
64
74
|
options.set_capability("deviceName", "Android Device")
|
|
75
|
+
if device_serial:
|
|
76
|
+
options.set_capability("udid", device_serial)
|
|
65
77
|
options.set_capability("noReset", True)
|
|
66
78
|
options.set_capability("fullReset", False)
|
|
79
|
+
options.set_capability("ignoreHiddenApiPolicyError", True)
|
|
67
80
|
self.default_wait_in_sec = 20
|
|
68
81
|
self.options = options
|
|
69
82
|
self.appium_url = appium_url or APPIUM_SERVER_URL
|
pantoqa_bridge/utils/misc.py
CHANGED
|
@@ -3,10 +3,15 @@ import functools
|
|
|
3
3
|
import os
|
|
4
4
|
import shutil
|
|
5
5
|
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
|
|
8
|
-
from pantoqa_bridge.config import APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, IS_WINDOWS
|
|
11
|
+
from pantoqa_bridge.config import (APPIUM_BIN, APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, IS_WINDOWS,
|
|
12
|
+
NODE_PATH)
|
|
9
13
|
from pantoqa_bridge.logger import logger
|
|
14
|
+
from pantoqa_bridge.utils.process import kill_process_by_port
|
|
10
15
|
|
|
11
16
|
|
|
12
17
|
def find_android_home() -> str | None:
|
|
@@ -75,51 +80,65 @@ def ensure_android_home() -> tuple[str | None, bool]:
|
|
|
75
80
|
return android_home, True
|
|
76
81
|
|
|
77
82
|
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
83
|
+
def start_appium_process_in_bg(on_exit: Callable[[int, int], None]):
|
|
84
|
+
|
|
85
|
+
def start():
|
|
86
|
+
if not APPIUM_BIN.endswith(".js"):
|
|
87
|
+
cmd = [
|
|
88
|
+
APPIUM_BIN,
|
|
89
|
+
"--session-override",
|
|
90
|
+
"--port",
|
|
91
|
+
str(APPIUM_SERVER_PORT),
|
|
92
|
+
"--address",
|
|
93
|
+
APPIUM_SERVER_HOST,
|
|
94
|
+
]
|
|
95
|
+
else:
|
|
96
|
+
cmd = [
|
|
97
|
+
NODE_PATH,
|
|
98
|
+
APPIUM_BIN,
|
|
99
|
+
"--session-override",
|
|
100
|
+
"--port",
|
|
101
|
+
str(APPIUM_SERVER_PORT),
|
|
102
|
+
"--address",
|
|
103
|
+
APPIUM_SERVER_HOST,
|
|
104
|
+
]
|
|
105
|
+
logger.info(f"Starting Appium at http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}")
|
|
106
|
+
|
|
107
|
+
proc = subprocess.Popen(
|
|
108
|
+
cmd,
|
|
109
|
+
stdout=subprocess.DEVNULL,
|
|
110
|
+
stderr=subprocess.DEVNULL,
|
|
111
|
+
start_new_session=True,
|
|
112
|
+
shell=False,
|
|
113
|
+
env=os.environ.copy(),
|
|
114
|
+
)
|
|
115
|
+
logger.info("Appium process started with PID: %d", proc.pid)
|
|
116
|
+
return proc
|
|
117
|
+
|
|
118
|
+
def start_in_loop():
|
|
119
|
+
max_retries = 2
|
|
120
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
121
|
+
while max_retries > 0:
|
|
122
|
+
max_retries -= 1
|
|
123
|
+
proc = start()
|
|
124
|
+
proc.wait()
|
|
125
|
+
if proc.returncode != 1:
|
|
126
|
+
break
|
|
127
|
+
logger.error("Appium process exited with errors. Return code: %d", proc.returncode)
|
|
128
|
+
kill_process_by_port(APPIUM_SERVER_PORT, timeout=5)
|
|
129
|
+
time.sleep(1)
|
|
130
|
+
if proc:
|
|
131
|
+
on_exit(proc.pid, proc.returncode)
|
|
132
|
+
else:
|
|
133
|
+
on_exit(-1, -1)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
kill_process_by_port(APPIUM_SERVER_PORT, timeout=5)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
thread = threading.Thread(target=start_in_loop, daemon=True)
|
|
140
|
+
thread.start()
|
|
141
|
+
logger.info("Starting Appium process in background.")
|
|
123
142
|
|
|
124
143
|
|
|
125
144
|
def make_sync(func):
|
pantoqa_bridge/utils/pkg.py
CHANGED
|
@@ -5,6 +5,7 @@ from importlib.metadata import version as importlib_version
|
|
|
5
5
|
|
|
6
6
|
from packaging import version as pkg_version
|
|
7
7
|
|
|
8
|
+
from pantoqa_bridge.config import IS_WINDOWS
|
|
8
9
|
from pantoqa_bridge.logger import logger
|
|
9
10
|
|
|
10
11
|
|
|
@@ -29,17 +30,24 @@ def get_latest_package_version(package_name: str) -> pkg_version.Version:
|
|
|
29
30
|
|
|
30
31
|
def upgrade_package(package_name: str) -> None:
|
|
31
32
|
logger.info(f"Upgrading package {package_name}...")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
try:
|
|
34
|
+
if shutil.which("pipx"):
|
|
35
|
+
subprocess.check_output(
|
|
36
|
+
["pipx", "upgrade", package_name],
|
|
37
|
+
stderr=subprocess.STDOUT,
|
|
38
|
+
text=True,
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
subprocess.check_output(
|
|
42
|
+
[sys.executable, "-m", "pipx", "upgrade", package_name],
|
|
43
|
+
stderr=subprocess.STDOUT,
|
|
44
|
+
text=True,
|
|
45
|
+
)
|
|
46
|
+
except subprocess.CalledProcessError as e:
|
|
47
|
+
if IS_WINDOWS and "PermissionError" in e.output and "WinError 32" in e.output:
|
|
48
|
+
return
|
|
49
|
+
logger.error(
|
|
50
|
+
f"Failed to upgrade package {package_name}: {e.output}. Return Code: {e.returncode}")
|
|
51
|
+
raise
|
|
44
52
|
|
|
45
53
|
logger.info(f"Package {package_name} upgraded successfully.")
|
pantoqa_bridge/utils/process.py
CHANGED
|
@@ -77,7 +77,7 @@ async def wait_for_port_to_alive(port: int, host: str = "127.0.0.1", timeout=15)
|
|
|
77
77
|
with socket.create_connection((host, port), timeout=1):
|
|
78
78
|
return True
|
|
79
79
|
except OSError:
|
|
80
|
-
await asyncio.sleep(0.
|
|
80
|
+
await asyncio.sleep(0.25)
|
|
81
81
|
raise TimeoutError("Appium did not start in time")
|
|
82
82
|
|
|
83
83
|
|
|
@@ -154,7 +154,7 @@ def kill_self_process():
|
|
|
154
154
|
kill_by_pid(self_pid)
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
def
|
|
157
|
+
def kill_process_gracefully(pid: int, timeout: int = 10) -> None:
|
|
158
158
|
if pid <= 0:
|
|
159
159
|
return
|
|
160
160
|
|
|
@@ -181,3 +181,47 @@ def kill_process_sync(pid: int, timeout: int = 10) -> None:
|
|
|
181
181
|
# Step 3: force kill
|
|
182
182
|
logger.info(f"Killing process {pid}...")
|
|
183
183
|
force_kill_by_pid(pid)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def find_pids_by_port(port: int) -> list[int]:
|
|
187
|
+
pids: list[int] = []
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
if IS_WINDOWS:
|
|
191
|
+
out = subprocess.check_output(
|
|
192
|
+
f'netstat -ano | findstr :{port}',
|
|
193
|
+
shell=True,
|
|
194
|
+
text=True,
|
|
195
|
+
)
|
|
196
|
+
for line in out.splitlines():
|
|
197
|
+
if "LISTENING" in line:
|
|
198
|
+
pid_str = line.split()[-1]
|
|
199
|
+
pids.append(int(pid_str))
|
|
200
|
+
|
|
201
|
+
return pids
|
|
202
|
+
|
|
203
|
+
out = subprocess.check_output(
|
|
204
|
+
["lsof", "-t", f"-i:{port}"],
|
|
205
|
+
text=True,
|
|
206
|
+
)
|
|
207
|
+
for pid_str in out.split():
|
|
208
|
+
pids.append(int(pid_str))
|
|
209
|
+
except subprocess.CalledProcessError:
|
|
210
|
+
# No process found
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
return pids
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def kill_process_by_port(port: int, timeout: int | None = None):
|
|
217
|
+
pids = find_pids_by_port(port)
|
|
218
|
+
for pid in pids:
|
|
219
|
+
logger.info(f"Killing process {pid} on port {port}...")
|
|
220
|
+
try:
|
|
221
|
+
if timeout and timeout > 0:
|
|
222
|
+
kill_process_gracefully(pid, timeout=timeout)
|
|
223
|
+
else:
|
|
224
|
+
kill_by_pid(pid)
|
|
225
|
+
except ProcessLookupError:
|
|
226
|
+
# Process already terminated
|
|
227
|
+
pass
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import adbutils # type: ignore
|
|
5
|
+
|
|
6
|
+
from pantoqa_bridge.config import INACTIVITY_TIMEOUT
|
|
7
|
+
from pantoqa_bridge.logger import logger
|
|
8
|
+
from pantoqa_bridge.models.misc import Action
|
|
9
|
+
from pantoqa_bridge.service.uiautomator_action import LocalBridgeService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceManager:
|
|
13
|
+
|
|
14
|
+
def __init__(self, device_serial: str):
|
|
15
|
+
self.device_serial = device_serial
|
|
16
|
+
self.srv = LocalBridgeService(device_serial)
|
|
17
|
+
self.last_used = time.time()
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
self._closed = False
|
|
20
|
+
|
|
21
|
+
self._cleanup_thread = threading.Thread(target=self._auto_cleanup, daemon=True)
|
|
22
|
+
self._cleanup_thread.start()
|
|
23
|
+
|
|
24
|
+
def touch(self):
|
|
25
|
+
with self._lock:
|
|
26
|
+
self.last_used = time.time()
|
|
27
|
+
|
|
28
|
+
def process(self, action: Action):
|
|
29
|
+
self.touch()
|
|
30
|
+
return _process_action(self.srv, action)
|
|
31
|
+
|
|
32
|
+
def _auto_cleanup(self):
|
|
33
|
+
while True:
|
|
34
|
+
time.sleep(30)
|
|
35
|
+
with self._lock:
|
|
36
|
+
if self._closed:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if time.time() - self.last_used > INACTIVITY_TIMEOUT:
|
|
40
|
+
self.close()
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
def close(self):
|
|
44
|
+
if self._closed:
|
|
45
|
+
return
|
|
46
|
+
self._closed = True
|
|
47
|
+
try:
|
|
48
|
+
self.srv.stop()
|
|
49
|
+
finally:
|
|
50
|
+
remove_service(self.device_serial)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_services: dict[str, ServiceManager] = {}
|
|
54
|
+
_services_lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_service(device_serial: str | None = None) -> ServiceManager:
|
|
58
|
+
adb = adbutils.AdbClient()
|
|
59
|
+
devices = adb.device_list()
|
|
60
|
+
|
|
61
|
+
if not device_serial:
|
|
62
|
+
device_serial = devices[0].serial
|
|
63
|
+
|
|
64
|
+
with _services_lock:
|
|
65
|
+
if device_serial not in _services:
|
|
66
|
+
_services[device_serial] = ServiceManager(device_serial)
|
|
67
|
+
return _services[device_serial]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def remove_service(device_serial: str):
|
|
71
|
+
with _services_lock:
|
|
72
|
+
_services.pop(device_serial, None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def remove_all_services():
|
|
76
|
+
with _services_lock:
|
|
77
|
+
services = list(_services.values())
|
|
78
|
+
_services.clear()
|
|
79
|
+
|
|
80
|
+
for srv_mgr in services:
|
|
81
|
+
try:
|
|
82
|
+
srv_mgr.close()
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.error(f"Error while closing service managers:{e}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _process_action(srv: LocalBridgeService, action: Action):
|
|
88
|
+
action_type = action.action_type
|
|
89
|
+
if action_type == "screenshot":
|
|
90
|
+
return srv.take_screenshot()
|
|
91
|
+
if action_type == "click":
|
|
92
|
+
params = action.params
|
|
93
|
+
assert params, "params is not present."
|
|
94
|
+
srv.click(params['x'], params['y'])
|
|
95
|
+
return
|
|
96
|
+
if action_type == "long_click":
|
|
97
|
+
params = action.params
|
|
98
|
+
assert params, "params is not present."
|
|
99
|
+
srv.long_click(params['x'], params['y'])
|
|
100
|
+
return
|
|
101
|
+
if action_type == "get_os_version":
|
|
102
|
+
return srv.get_os_version()
|
|
103
|
+
if action_type == "get_device_model":
|
|
104
|
+
return srv.get_device_model()
|
|
105
|
+
if action_type == "get_device_resolution":
|
|
106
|
+
return srv.device_resolution()
|
|
107
|
+
if action_type == "get_ui_dump":
|
|
108
|
+
return srv.get_ui_dump()
|
|
109
|
+
if action_type == "get_current_app_activity":
|
|
110
|
+
return srv.get_current_app_activity()
|
|
111
|
+
if action_type == "get_main_activity":
|
|
112
|
+
params = action.params
|
|
113
|
+
assert params, "params is not present."
|
|
114
|
+
return srv.get_main_activity(params['package_name'])
|
|
115
|
+
if action_type == "get_all_packages":
|
|
116
|
+
return srv.get_all_packages()
|
|
117
|
+
if action_type == "open_app":
|
|
118
|
+
params = action.params
|
|
119
|
+
assert params, "params is not present."
|
|
120
|
+
srv.open_app(params['package_name'], params['activity_name'])
|
|
121
|
+
return
|
|
122
|
+
if action_type == "close_keyboard":
|
|
123
|
+
srv.close_keyboard()
|
|
124
|
+
return
|
|
125
|
+
if action_type == "press_enter":
|
|
126
|
+
srv.press_enter()
|
|
127
|
+
return
|
|
128
|
+
if action_type == "go_back":
|
|
129
|
+
srv.go_back()
|
|
130
|
+
return
|
|
131
|
+
if action_type == "is_keyboard_open":
|
|
132
|
+
return srv.is_keyboard_open()
|
|
133
|
+
if action_type == "goto_home":
|
|
134
|
+
srv.goto_home()
|
|
135
|
+
return
|
|
136
|
+
if action_type == "clear_all_inputs":
|
|
137
|
+
srv.clear_all_inputs()
|
|
138
|
+
return
|
|
139
|
+
if action_type == "backspace":
|
|
140
|
+
srv.backspace()
|
|
141
|
+
return
|
|
142
|
+
if action_type == "input_text":
|
|
143
|
+
params = action.params
|
|
144
|
+
assert params, "params is not present."
|
|
145
|
+
srv.input_text(params['text'])
|
|
146
|
+
return
|
|
147
|
+
if action_type == "swipe":
|
|
148
|
+
params = action.params
|
|
149
|
+
assert params, "params is not present."
|
|
150
|
+
srv.swipe(params['x1'], params['y1'], params['x2'], params['y2'])
|
|
151
|
+
return
|
|
152
|
+
if action_type == "long_press":
|
|
153
|
+
params = action.params
|
|
154
|
+
assert params, "params is not present."
|
|
155
|
+
srv.long_press(params['x'], params['y'])
|
|
156
|
+
return
|
|
157
|
+
if action_type == "get_oem_name":
|
|
158
|
+
return srv.get_oem_name()
|
|
159
|
+
if action_type == "get_device_name":
|
|
160
|
+
return srv.get_device_name()
|
|
161
|
+
if action_type == "get_os_build_version":
|
|
162
|
+
return srv.get_os_build_version()
|
|
163
|
+
if action_type == "stop":
|
|
164
|
+
return srv.stop()
|
|
165
|
+
|
|
166
|
+
raise ValueError(f"Unsupported action type: {action_type}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pantoqa_bridge
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.20
|
|
4
4
|
Summary: Panto QA Bridge
|
|
5
5
|
Author-email: Ritwick Dey <ritwick@getpanto.ai>
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -14,7 +14,7 @@ Requires-Dist: aiohttp>=3.13.2
|
|
|
14
14
|
Requires-Dist: rich>=14.2.0
|
|
15
15
|
Requires-Dist: uiautomator2>=3.5.0
|
|
16
16
|
Requires-Dist: adbutils>=2.12.0
|
|
17
|
-
Requires-Dist: appium-utility>=0.3.
|
|
17
|
+
Requires-Dist: appium-utility>=0.3.7
|
|
18
18
|
Requires-Dist: packaging>=25.0
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
pantoqa_bridge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pantoqa_bridge/__main__.py,sha256=5m0xHXIz0lDmzAOmkxmaRSUUVKA6_CDisaFxfd9Xe08,57
|
|
3
|
+
pantoqa_bridge/cli.py,sha256=FiY0pBJoWO7rD8sEUNsu3cRISu7ZSvDasl7iTESuqGk,2378
|
|
4
|
+
pantoqa_bridge/config.py,sha256=i9zBJ4gFYwx08kHUOTlwoJPi1HFyTRzMqAVwJZ-iSYw,1105
|
|
5
|
+
pantoqa_bridge/logger.py,sha256=emPHG2YMPvHifHVbmzh3G2ZHDiX7tiS-iB4mUtSMfMY,899
|
|
6
|
+
pantoqa_bridge/scrcpy-server-v3.3.1,sha256=oPcLIKpJmPv2WMlBGM1sjatqu7Bkejvas0TXC8Hry7g,90788
|
|
7
|
+
pantoqa_bridge/server.py,sha256=Vj2bAmxVWoxq_C7K49997JubzJvyGwjA01HIx8x2g-Y,6570
|
|
8
|
+
pantoqa_bridge/lib/adb.py,sha256=TBekDIDTh5iUNphXxDwZDSsj8JghJ6sc9JGAIJvFTHM,2605
|
|
9
|
+
pantoqa_bridge/lib/scrcpy.py,sha256=On-VHpKBOv7M6y9_2xtlcEDpXqYpYL-C9AnwgZuSnuw,4447
|
|
10
|
+
pantoqa_bridge/models/code_execute.py,sha256=SsvzgBmP1FweXhRXIirmQ_R2EtFmf6Kox567mrGht9U,87
|
|
11
|
+
pantoqa_bridge/models/misc.py,sha256=5EQDdxOzItoD_n8nWbvh5V5pPGplnuqlWZYOks1h8I4,106
|
|
12
|
+
pantoqa_bridge/routes/action.py,sha256=PM5Rm8epADNXIAZvOx_z4B0MCYUnlYLODRdmPqe4q8s,723
|
|
13
|
+
pantoqa_bridge/routes/adb.py,sha256=sDy3UjeB9bNZPn7w07avoLKJ1K3KCk0V5gSX3pxoDdE,369
|
|
14
|
+
pantoqa_bridge/routes/executor.py,sha256=HdQXCJNr6deiPCteu0ZrysE9cXlETmME4oQlghegtdA,4078
|
|
15
|
+
pantoqa_bridge/routes/misc.py,sha256=TWz9aErGGdg96K-wnxvCfh2vjmdZmJEy9Wmih0YsbWg,2122
|
|
16
|
+
pantoqa_bridge/routes/screen_mirror.py,sha256=EidHlobyMNLJwQrO5T7Gb-JTY3CjGe0a9IiLiw1AcQ4,2429
|
|
17
|
+
pantoqa_bridge/service/uiautomator_action.py,sha256=B-5iPzYBiG3zfjsUieDAKvKQgmo9d6fDKNouG87duDI,4427
|
|
18
|
+
pantoqa_bridge/tasks/executor.py,sha256=cW-u_BK6QSTZ1Kex4kuZBhibP6qWR-XI10UALYwbQw0,3109
|
|
19
|
+
pantoqa_bridge/utils/misc.py,sha256=o-Zrpq2ON6MTU0ZKBi5ZRKPhIPBm1swhyEOVS7xdG-k,3948
|
|
20
|
+
pantoqa_bridge/utils/pkg.py,sha256=N9z_C7aQYU2VAdp3ptigp50331pxTwErVrT5I9Htz-c,1634
|
|
21
|
+
pantoqa_bridge/utils/process.py,sha256=M9xMNn8xLo_PLWlCkN7bQCvYGuOg1nWvqzbpx6ifJTA,5703
|
|
22
|
+
pantoqa_bridge/utils/service_manager.py,sha256=gycC6Nn9Cd9tNwZ3R9M2DfeFq1GTO3f74XUoCN3nGeM,4601
|
|
23
|
+
pantoqa_bridge-0.4.20.dist-info/METADATA,sha256=gnDd7XG-pc-BEiLDWlCIGccUsIDuvrekHadwAxVOcX8,1351
|
|
24
|
+
pantoqa_bridge-0.4.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
25
|
+
pantoqa_bridge-0.4.20.dist-info/entry_points.txt,sha256=GBytwhIuiZGX_cj_1JQpUteBsP51TSxTk4NxTp4bN_I,58
|
|
26
|
+
pantoqa_bridge-0.4.20.dist-info/top_level.txt,sha256=r03tgM1pQrHwfxF9gkvU94HgR_s8tqDvZVSAzq8qwrA,15
|
|
27
|
+
pantoqa_bridge-0.4.20.dist-info/RECORD,,
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
pantoqa_bridge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
pantoqa_bridge/__main__.py,sha256=5m0xHXIz0lDmzAOmkxmaRSUUVKA6_CDisaFxfd9Xe08,57
|
|
3
|
-
pantoqa_bridge/cli.py,sha256=1EK3P3XcmFl4Il3-KNBNWddbL11ENR7Fi8zstXj1QIM,2016
|
|
4
|
-
pantoqa_bridge/config.py,sha256=yJ_860ih9BG5e6n70tN_3EkpxCLzjgrpvDx-QFo86Fc,852
|
|
5
|
-
pantoqa_bridge/logger.py,sha256=emPHG2YMPvHifHVbmzh3G2ZHDiX7tiS-iB4mUtSMfMY,899
|
|
6
|
-
pantoqa_bridge/scrcpy-server-v3.3.1,sha256=oPcLIKpJmPv2WMlBGM1sjatqu7Bkejvas0TXC8Hry7g,90788
|
|
7
|
-
pantoqa_bridge/server.py,sha256=tFvnQ9xbJQ14vuDEuJLuisxwdaXAIDhiBou8Yy59PGA,6509
|
|
8
|
-
pantoqa_bridge/lib/adb.py,sha256=_4Vd3VUfklRqHFLhoMQdbzH2ZnS-lhQcCwu-p3a-SLY,2532
|
|
9
|
-
pantoqa_bridge/lib/scrcpy.py,sha256=UNesa2XrV54r56MIm15X67HLka94I6mheNqKh4Bw54A,4023
|
|
10
|
-
pantoqa_bridge/models/code_execute.py,sha256=SsvzgBmP1FweXhRXIirmQ_R2EtFmf6Kox567mrGht9U,87
|
|
11
|
-
pantoqa_bridge/routes/action.py,sha256=pplP4eQI1qK7LIKxXb4I5j6tkcT6MowoIAtz-RMPwQI,3339
|
|
12
|
-
pantoqa_bridge/routes/adb.py,sha256=sDy3UjeB9bNZPn7w07avoLKJ1K3KCk0V5gSX3pxoDdE,369
|
|
13
|
-
pantoqa_bridge/routes/executor.py,sha256=gHRlNcncohPsD-VtaFTTBcu6JHrD4F-LvvETdoisuZI,3503
|
|
14
|
-
pantoqa_bridge/routes/misc.py,sha256=TWz9aErGGdg96K-wnxvCfh2vjmdZmJEy9Wmih0YsbWg,2122
|
|
15
|
-
pantoqa_bridge/routes/screen_mirror.py,sha256=KXpqKC0GzTW5JAoLZ0cKUY1RqZE3bNpk3zVVc5MReRE,2380
|
|
16
|
-
pantoqa_bridge/service/uiautomator_action.py,sha256=UE_5-oyE_z5SjGa6Mtioap5jEM5MR9leXTdRuDl-vDI,2766
|
|
17
|
-
pantoqa_bridge/tasks/executor.py,sha256=PWAS5hP79nYMLz4sNHLEYhF33YmslbcrjnqVr78rs2k,2683
|
|
18
|
-
pantoqa_bridge/utils/misc.py,sha256=8tFn8p2OhLNIdT31w0LczQ8hjkhkpx3fkaeeMgs71sM,3668
|
|
19
|
-
pantoqa_bridge/utils/pkg.py,sha256=difSA1MW6auFUNzOH7Emmukh5mqdsKb-Ow3jdiKKatg,1297
|
|
20
|
-
pantoqa_bridge/utils/process.py,sha256=O71jC8twabT2MfcowAlMTOFeCQRb17MGTc3BFG4E9Bs,4674
|
|
21
|
-
pantoqa_bridge-0.4.4.dist-info/METADATA,sha256=XuyB6iCj-9sH0RyU4h7Xzr2pZylaAkNJJi02z-PLXBE,1350
|
|
22
|
-
pantoqa_bridge-0.4.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
23
|
-
pantoqa_bridge-0.4.4.dist-info/entry_points.txt,sha256=GBytwhIuiZGX_cj_1JQpUteBsP51TSxTk4NxTp4bN_I,58
|
|
24
|
-
pantoqa_bridge-0.4.4.dist-info/top_level.txt,sha256=r03tgM1pQrHwfxF9gkvU94HgR_s8tqDvZVSAzq8qwrA,15
|
|
25
|
-
pantoqa_bridge-0.4.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|