pantoqa-bridge 0.4.4__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/__init__.py +0 -0
- pantoqa_bridge/__main__.py +4 -0
- pantoqa_bridge/cli.py +58 -0
- pantoqa_bridge/config.py +35 -0
- pantoqa_bridge/lib/adb.py +112 -0
- pantoqa_bridge/lib/scrcpy.py +119 -0
- pantoqa_bridge/logger.py +39 -0
- pantoqa_bridge/models/code_execute.py +6 -0
- pantoqa_bridge/routes/action.py +114 -0
- pantoqa_bridge/routes/adb.py +16 -0
- pantoqa_bridge/routes/executor.py +117 -0
- pantoqa_bridge/routes/misc.py +74 -0
- pantoqa_bridge/routes/screen_mirror.py +75 -0
- pantoqa_bridge/scrcpy-server-v3.3.1 +0 -0
- pantoqa_bridge/server.py +178 -0
- pantoqa_bridge/service/uiautomator_action.py +102 -0
- pantoqa_bridge/tasks/executor.py +88 -0
- pantoqa_bridge/utils/misc.py +131 -0
- pantoqa_bridge/utils/pkg.py +45 -0
- pantoqa_bridge/utils/process.py +183 -0
- pantoqa_bridge-0.4.4.dist-info/METADATA +48 -0
- pantoqa_bridge-0.4.4.dist-info/RECORD +25 -0
- pantoqa_bridge-0.4.4.dist-info/WHEEL +5 -0
- pantoqa_bridge-0.4.4.dist-info/entry_points.txt +2 -0
- pantoqa_bridge-0.4.4.dist-info/top_level.txt +1 -0
|
File without changes
|
pantoqa_bridge/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from pantoqa_bridge.config import PKG_NAME, SERVER_HOST, SERVER_PORT
|
|
4
|
+
from pantoqa_bridge.server import start_bridge_server
|
|
5
|
+
from pantoqa_bridge.tasks.executor import AppiumExecutable, MaestroExecutable, QAExecutable
|
|
6
|
+
from pantoqa_bridge.utils.misc import make_sync
|
|
7
|
+
from pantoqa_bridge.utils.pkg import get_pkg_version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group(help="PantoAI QA Extension CLI", invoke_without_command=True)
|
|
11
|
+
@click.option("--version", is_flag=True, help="Show version and exit")
|
|
12
|
+
@click.pass_context
|
|
13
|
+
def cli(ctx: click.Context, version: bool) -> None:
|
|
14
|
+
if version:
|
|
15
|
+
click.echo(f"PantoQA Bridge version: {get_pkg_version(PKG_NAME)}")
|
|
16
|
+
ctx.exit(0)
|
|
17
|
+
|
|
18
|
+
if ctx.invoked_subcommand is None:
|
|
19
|
+
ctx.invoke(serve)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
@click.option("--host", default=SERVER_HOST, show_default=True, help="Bind address")
|
|
24
|
+
@click.option("--port", default=SERVER_PORT, show_default=True, type=int, help="Port to listen on")
|
|
25
|
+
def serve(host: str, port: int) -> None:
|
|
26
|
+
start_bridge_server(host, port)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cli.command()
|
|
30
|
+
@click.option("--framework",
|
|
31
|
+
type=click.Choice(["appium", "maestro"], case_sensitive=False),
|
|
32
|
+
required=True,
|
|
33
|
+
help="QA framework to use")
|
|
34
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
|
|
35
|
+
@click.option("--maestro-bin", help="Path to Maestro binary (for maestro framework)")
|
|
36
|
+
@click.option("--appium-url", help="Appium server URL (for appium framework)")
|
|
37
|
+
@make_sync
|
|
38
|
+
async def execute(
|
|
39
|
+
framework: str,
|
|
40
|
+
files: list[str],
|
|
41
|
+
maestro_bin: str | None = None,
|
|
42
|
+
appium_url: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
|
|
45
|
+
executable: QAExecutable | None = None
|
|
46
|
+
if framework.lower() == "maestro":
|
|
47
|
+
executable = MaestroExecutable(files=files, maestro_bin=maestro_bin)
|
|
48
|
+
elif framework.lower() == "appium":
|
|
49
|
+
executable = AppiumExecutable(files=files, appium_url=appium_url)
|
|
50
|
+
else:
|
|
51
|
+
raise click.ClickException(f"Unsupported framework: {framework}")
|
|
52
|
+
|
|
53
|
+
await executable.execute()
|
|
54
|
+
click.echo("Execution completed.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
cli()
|
pantoqa_bridge/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
load_dotenv(
|
|
8
|
+
dotenv_path=".envrc",
|
|
9
|
+
verbose=True,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
PKG_NAME = "pantoqa_bridge"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resource_path(path: str) -> str:
|
|
16
|
+
try:
|
|
17
|
+
return str(files(PKG_NAME).joinpath(path))
|
|
18
|
+
except Exception:
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SCRCPY_SERVER_BIN = resource_path("scrcpy-server-v3.3.1")
|
|
23
|
+
SCRCPY_SERVER_VERSION = "3.3.1"
|
|
24
|
+
SCRCPY_SOCKET_PORT = 27888
|
|
25
|
+
|
|
26
|
+
IS_WINDOWS = platform.system() == "Windows"
|
|
27
|
+
|
|
28
|
+
SERVER_HOST = os.getenv("SERVER_HOST") or ("0.0.0.0" if not IS_WINDOWS else "127.0.0.1")
|
|
29
|
+
SERVER_PORT = int(os.getenv("SERVER_PORT") or "6565")
|
|
30
|
+
|
|
31
|
+
APPIUM_SERVER_HOST = os.getenv("APPIUM_SERVER_HOST") or SERVER_HOST
|
|
32
|
+
APPIUM_SERVER_PORT = int(os.getenv("APPIUM_SERVER_PORT") or "6566")
|
|
33
|
+
APPIUM_SERVER_URL = f"http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}"
|
|
34
|
+
|
|
35
|
+
MAESTRO_BIN = "maestro"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
from asyncio.subprocess import Process
|
|
4
|
+
from typing import Literal, overload
|
|
5
|
+
|
|
6
|
+
ADB_BIN = "adb"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ADB:
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
adb: str = ADB_BIN,
|
|
14
|
+
device_serial: str | None = None,
|
|
15
|
+
):
|
|
16
|
+
self.adb = adb
|
|
17
|
+
self.device_serial = device_serial
|
|
18
|
+
|
|
19
|
+
async def device_list(self) -> list[str]:
|
|
20
|
+
cmd = ["devices"]
|
|
21
|
+
res = await self._run_adb_cmd(cmd)
|
|
22
|
+
lines = res.stdout.decode().splitlines()
|
|
23
|
+
devices = []
|
|
24
|
+
for line in lines[1:]:
|
|
25
|
+
if line.strip():
|
|
26
|
+
device_id = line.split("\t")[0]
|
|
27
|
+
devices.append(device_id)
|
|
28
|
+
return devices
|
|
29
|
+
|
|
30
|
+
async def push(
|
|
31
|
+
self,
|
|
32
|
+
local_path: str,
|
|
33
|
+
remote_path: str,
|
|
34
|
+
) -> subprocess.CompletedProcess:
|
|
35
|
+
cmd = ["push", local_path, remote_path]
|
|
36
|
+
res = await self._run_adb_cmd(cmd)
|
|
37
|
+
return res
|
|
38
|
+
|
|
39
|
+
async def shell(self, command: str) -> subprocess.CompletedProcess:
|
|
40
|
+
cmd = ["shell", command]
|
|
41
|
+
res = await self._run_adb_cmd(cmd)
|
|
42
|
+
return res
|
|
43
|
+
|
|
44
|
+
async def exec(
|
|
45
|
+
self,
|
|
46
|
+
command: list[str],
|
|
47
|
+
skip_check: bool = False,
|
|
48
|
+
) -> subprocess.CompletedProcess:
|
|
49
|
+
return await self._run_adb_cmd(
|
|
50
|
+
command,
|
|
51
|
+
skip_check=skip_check,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def exec_p(
|
|
55
|
+
self,
|
|
56
|
+
command: list[str],
|
|
57
|
+
) -> Process:
|
|
58
|
+
return await self._run_adb_cmd(command, return_process=True)
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
async def _run_adb_cmd(
|
|
62
|
+
self,
|
|
63
|
+
cmd: list[str],
|
|
64
|
+
*,
|
|
65
|
+
return_process: Literal[True],
|
|
66
|
+
skip_check: bool = False,
|
|
67
|
+
) -> Process:
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
@overload
|
|
71
|
+
async def _run_adb_cmd(
|
|
72
|
+
self,
|
|
73
|
+
cmd: list[str],
|
|
74
|
+
*,
|
|
75
|
+
return_process: Literal[False] = False,
|
|
76
|
+
skip_check: bool = False,
|
|
77
|
+
) -> subprocess.CompletedProcess:
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
async def _run_adb_cmd(
|
|
81
|
+
self,
|
|
82
|
+
cmd: list[str],
|
|
83
|
+
*,
|
|
84
|
+
return_process: bool = False,
|
|
85
|
+
skip_check=False,
|
|
86
|
+
) -> subprocess.CompletedProcess | Process:
|
|
87
|
+
if self.device_serial:
|
|
88
|
+
cmds = [self.adb, "-s", self.device_serial] + cmd
|
|
89
|
+
else:
|
|
90
|
+
cmds = [self.adb] + cmd
|
|
91
|
+
|
|
92
|
+
print(f"Executing ADB command: {' '.join(cmds)}")
|
|
93
|
+
|
|
94
|
+
process = await asyncio.create_subprocess_exec(
|
|
95
|
+
*cmds,
|
|
96
|
+
stdout=asyncio.subprocess.PIPE,
|
|
97
|
+
stderr=asyncio.subprocess.PIPE,
|
|
98
|
+
)
|
|
99
|
+
if return_process:
|
|
100
|
+
return process
|
|
101
|
+
|
|
102
|
+
stdout, stderr = await process.communicate()
|
|
103
|
+
|
|
104
|
+
if not skip_check and process.returncode != 0:
|
|
105
|
+
raise subprocess.CalledProcessError(
|
|
106
|
+
process.returncode or -1,
|
|
107
|
+
cmds,
|
|
108
|
+
output=stdout,
|
|
109
|
+
stderr=stderr,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return subprocess.CompletedProcess(cmds, process.returncode or 0, stdout, stderr)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import signal
|
|
3
|
+
import socket
|
|
4
|
+
from asyncio.subprocess import Process
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from pantoqa_bridge.config import SCRCPY_SERVER_BIN, SCRCPY_SERVER_VERSION, SCRCPY_SOCKET_PORT
|
|
8
|
+
from pantoqa_bridge.lib.adb import ADB
|
|
9
|
+
|
|
10
|
+
# ADB = "adb"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidDummyByte(Exception):
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScrcpyPyClient:
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
on_frame_update: Callable[[bytes], Awaitable[None]],
|
|
22
|
+
max_size: int | None = None,
|
|
23
|
+
server_version: str = SCRCPY_SERVER_VERSION,
|
|
24
|
+
sever_port: int = SCRCPY_SOCKET_PORT,
|
|
25
|
+
server_bin: str = SCRCPY_SERVER_BIN,
|
|
26
|
+
adb: ADB | None = None,
|
|
27
|
+
device_serial: str | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.on_frame = on_frame_update
|
|
30
|
+
self.device_serial = device_serial
|
|
31
|
+
self.adb = adb or ADB(device_serial=self.device_serial)
|
|
32
|
+
self.server_bin = server_bin
|
|
33
|
+
self.socket_port = sever_port
|
|
34
|
+
self.server_version = server_version
|
|
35
|
+
self.max_size = max_size or 720
|
|
36
|
+
self._is_stopping = asyncio.Event()
|
|
37
|
+
self._remote_tmp_path = f"/data/local/tmp/scrcpy-server{server_version}"
|
|
38
|
+
self._scrcpy_process: Process | None = None
|
|
39
|
+
|
|
40
|
+
async def push_server(self):
|
|
41
|
+
await self.adb.push(self.server_bin, self._remote_tmp_path)
|
|
42
|
+
|
|
43
|
+
async def start_server(self):
|
|
44
|
+
cmd = [
|
|
45
|
+
"shell",
|
|
46
|
+
f"CLASSPATH={self._remote_tmp_path}",
|
|
47
|
+
"app_process",
|
|
48
|
+
"/",
|
|
49
|
+
"com.genymobile.scrcpy.Server",
|
|
50
|
+
self.server_version,
|
|
51
|
+
"control=false",
|
|
52
|
+
"audio=false",
|
|
53
|
+
"tunnel_forward=true",
|
|
54
|
+
f"max_size={self.max_size}",
|
|
55
|
+
]
|
|
56
|
+
process = await self.adb.exec_p(cmd)
|
|
57
|
+
self._scrcpy_process = process
|
|
58
|
+
assert process.stdout, "Scrcpy process stdout is None."
|
|
59
|
+
data = await process.stdout.readline() # some delay until server starts
|
|
60
|
+
print(f"[Scrcpy]: {data!r}")
|
|
61
|
+
print("Scrcpy server started.")
|
|
62
|
+
|
|
63
|
+
async def forward_video_socket(self):
|
|
64
|
+
cmd = ["forward", f"tcp:{self.socket_port}", "localabstract:scrcpy"]
|
|
65
|
+
await self.adb.exec(cmd)
|
|
66
|
+
|
|
67
|
+
async def read_video_socket(self, connect_timeout: int = 30):
|
|
68
|
+
|
|
69
|
+
async def connect() -> socket.socket:
|
|
70
|
+
print("Trying to connect to video socket...")
|
|
71
|
+
sock = socket.create_connection(("127.0.0.1", self.socket_port), timeout=5)
|
|
72
|
+
sock.setblocking(False)
|
|
73
|
+
loop = asyncio.get_running_loop()
|
|
74
|
+
DUMMY_FIELD_LENGTH = 1
|
|
75
|
+
dummy_byte = await loop.sock_recv(sock, DUMMY_FIELD_LENGTH)
|
|
76
|
+
if not len(dummy_byte) or dummy_byte != b"\x00":
|
|
77
|
+
print(f"Received invalid dummy byte: {dummy_byte!r}")
|
|
78
|
+
sock.close()
|
|
79
|
+
raise InvalidDummyByte("invalid_dummy_byte")
|
|
80
|
+
|
|
81
|
+
return sock
|
|
82
|
+
|
|
83
|
+
async def connect_with_retries(connect_timeout: int = 30) -> socket.socket:
|
|
84
|
+
start_time = asyncio.get_running_loop().time()
|
|
85
|
+
while True:
|
|
86
|
+
print("Attempting to connect to scrcpy video socket...")
|
|
87
|
+
try:
|
|
88
|
+
sock = await connect()
|
|
89
|
+
return sock
|
|
90
|
+
except InvalidDummyByte as e:
|
|
91
|
+
if asyncio.get_running_loop().time() - start_time > connect_timeout:
|
|
92
|
+
raise TimeoutError("Timeout connecting to scrcpy video socket.")
|
|
93
|
+
print(f"Connection to video socket failed: {e}. Retrying...")
|
|
94
|
+
await asyncio.sleep(1)
|
|
95
|
+
|
|
96
|
+
sock = await connect_with_retries(connect_timeout)
|
|
97
|
+
print("Connected to video socket. Streaming video data...")
|
|
98
|
+
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
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
|
+
print("No more data from video stream. Exiting...")
|
|
105
|
+
break
|
|
106
|
+
await self.on_frame(raw_data)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"Error reading video stream: {e}")
|
|
109
|
+
raise e
|
|
110
|
+
|
|
111
|
+
async def stop(self):
|
|
112
|
+
self._is_stopping.set()
|
|
113
|
+
cmd = ["forward", "--remove", f"tcp:{self.socket_port}"]
|
|
114
|
+
await self.adb.exec(cmd, skip_check=True)
|
|
115
|
+
print("Stopped scrcpy client.")
|
|
116
|
+
if self._scrcpy_process:
|
|
117
|
+
print("Terminating scrcpy server process...")
|
|
118
|
+
self._scrcpy_process.send_signal(signal.SIGTERM)
|
|
119
|
+
await self._scrcpy_process.wait()
|
pantoqa_bridge/logger.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from rich.logging import RichHandler
|
|
4
|
+
|
|
5
|
+
logging.getLogger("httpx").setLevel(logging.ERROR)
|
|
6
|
+
|
|
7
|
+
_logger_name = 'pantoqa.automations'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logger():
|
|
11
|
+
formatter = logging.Formatter('%(levelname)s: %(asctime)s %(filename)s:%(lineno)d %(message)s')
|
|
12
|
+
loghandler = logging.StreamHandler()
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(_logger_name)
|
|
15
|
+
logger.setLevel(logging.INFO)
|
|
16
|
+
loghandler.setFormatter(formatter)
|
|
17
|
+
logger.addHandler(loghandler)
|
|
18
|
+
logger.propagate = False
|
|
19
|
+
return logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup_logger_cli():
|
|
23
|
+
formatter = logging.Formatter("%(message)s")
|
|
24
|
+
loghandler = RichHandler(
|
|
25
|
+
markup=True,
|
|
26
|
+
rich_tracebacks=False,
|
|
27
|
+
show_time=False,
|
|
28
|
+
show_path=False,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(_logger_name)
|
|
32
|
+
logger.setLevel(logging.INFO)
|
|
33
|
+
loghandler.setFormatter(formatter)
|
|
34
|
+
logger.addHandler(loghandler)
|
|
35
|
+
logger.propagate = False
|
|
36
|
+
return logger
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
logger = setup_logger_cli()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from pantoqa_bridge.service.uiautomator_action import LocalBridgeService
|
|
7
|
+
|
|
8
|
+
router = APIRouter()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Action(BaseModel):
|
|
12
|
+
action_type: str
|
|
13
|
+
params: dict | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ActionRequestModel(BaseModel):
|
|
17
|
+
id: str
|
|
18
|
+
action: Action
|
|
19
|
+
device_serial_no: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ActionResponseModel(BaseModel):
|
|
23
|
+
id: str
|
|
24
|
+
type: str
|
|
25
|
+
data: Any | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post('/perform-action', response_model=ActionResponseModel)
|
|
29
|
+
async def perform_action(request: ActionRequestModel):
|
|
30
|
+
action = request.action
|
|
31
|
+
srv = LocalBridgeService(request.device_serial_no)
|
|
32
|
+
result = _process_action(srv, action)
|
|
33
|
+
response = ActionResponseModel(id=request.id, type="driver_action_response", data=result)
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _process_action(srv: LocalBridgeService, action: Action):
|
|
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}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from adbutils import adb # type:ignore
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ADBDeviceList(BaseModel):
|
|
7
|
+
serial_no: str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("/get-devices", response_model=list[ADBDeviceList])
|
|
14
|
+
async def get_available_devices():
|
|
15
|
+
devices = adb.device_list()
|
|
16
|
+
return [ADBDeviceList(serial_no=d.serial) for d in devices]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from pantoqa_bridge.config import PKG_NAME
|
|
14
|
+
from pantoqa_bridge.logger import logger
|
|
15
|
+
from pantoqa_bridge.models.code_execute import CodeFile
|
|
16
|
+
from pantoqa_bridge.utils.process import create_stream_pipe, stream_process
|
|
17
|
+
|
|
18
|
+
route = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExecutionRequest(BaseModel):
|
|
22
|
+
files: list[CodeFile]
|
|
23
|
+
framework: Literal['APPIUM', 'MAESTRO']
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExecutionResult(BaseModel):
|
|
27
|
+
status: str
|
|
28
|
+
exit_code: int | None = None
|
|
29
|
+
message: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@route.post("/execute")
|
|
33
|
+
async def execute(rawrequest: Request) -> StreamingResponse:
|
|
34
|
+
request_json = await rawrequest.json()
|
|
35
|
+
request = ExecutionRequest.model_validate(request_json)
|
|
36
|
+
if not request.files:
|
|
37
|
+
raise HTTPException(status_code=400, detail="At least one file is required")
|
|
38
|
+
|
|
39
|
+
stream, push_to_stream, done = create_stream_pipe()
|
|
40
|
+
|
|
41
|
+
async def emit(event: str, data: str) -> None:
|
|
42
|
+
logger.info(f"[SSE]:{event}, data: {data}")
|
|
43
|
+
await push_to_stream(_format_sse(event, data))
|
|
44
|
+
|
|
45
|
+
async def task():
|
|
46
|
+
try:
|
|
47
|
+
await _execute_qacode(request, emit)
|
|
48
|
+
finally:
|
|
49
|
+
done.set()
|
|
50
|
+
|
|
51
|
+
asyncio.create_task(task())
|
|
52
|
+
return StreamingResponse(stream, media_type="text/event-stream")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_sse(event: str, data: str) -> str:
|
|
56
|
+
json_dict = {
|
|
57
|
+
"event": event,
|
|
58
|
+
"data": data,
|
|
59
|
+
}
|
|
60
|
+
json_str = json.dumps(json_dict)
|
|
61
|
+
return f"data: {json_str}\n\n"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _execute_qacode(
|
|
65
|
+
request: ExecutionRequest,
|
|
66
|
+
on_data: Callable[[str, str], Awaitable[None]],
|
|
67
|
+
):
|
|
68
|
+
logger.info(f"Executing QA code with framework: {request.framework}")
|
|
69
|
+
copiedfiles: list[str] = []
|
|
70
|
+
with tempfile.TemporaryDirectory(prefix="pantoqa-qa-run-") as tmpdir:
|
|
71
|
+
workdir = Path(tmpdir)
|
|
72
|
+
for code_file in request.files:
|
|
73
|
+
target = workdir / code_file.path
|
|
74
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
target.write_text(code_file.content)
|
|
76
|
+
copiedfiles.append(str(target))
|
|
77
|
+
|
|
78
|
+
await on_data("status", "Starting testing...")
|
|
79
|
+
|
|
80
|
+
process: asyncio.subprocess.Process | None = None
|
|
81
|
+
try:
|
|
82
|
+
cmd = [
|
|
83
|
+
sys.executable,
|
|
84
|
+
"-u", # unbuffered output
|
|
85
|
+
"-m",
|
|
86
|
+
PKG_NAME,
|
|
87
|
+
"execute",
|
|
88
|
+
"--framework",
|
|
89
|
+
request.framework.lower(),
|
|
90
|
+
*copiedfiles,
|
|
91
|
+
]
|
|
92
|
+
process = await asyncio.create_subprocess_exec(
|
|
93
|
+
*cmd,
|
|
94
|
+
stdout=asyncio.subprocess.PIPE,
|
|
95
|
+
stderr=asyncio.subprocess.PIPE,
|
|
96
|
+
cwd=workdir,
|
|
97
|
+
start_new_session=True,
|
|
98
|
+
# env={**os.environ.copy(), "PYTHONUNBUFFERED": "1"},
|
|
99
|
+
)
|
|
100
|
+
logger.info(f"Started execution process with PID: {process.pid}")
|
|
101
|
+
await stream_process(process, on_data)
|
|
102
|
+
exit_code = process.returncode
|
|
103
|
+
except asyncio.CancelledError:
|
|
104
|
+
if process and process.returncode is None:
|
|
105
|
+
process.kill()
|
|
106
|
+
await on_data("status", "Execution cancelled.")
|
|
107
|
+
raise
|
|
108
|
+
except Exception as e:
|
|
109
|
+
if process and process.returncode is None:
|
|
110
|
+
process.kill()
|
|
111
|
+
exit_code = process.returncode if process else -1
|
|
112
|
+
await on_data("exception", f"Execution failed: {str(e)}")
|
|
113
|
+
logger.exception(f"Execution failed: {str(e)}")
|
|
114
|
+
|
|
115
|
+
await on_data("status", f"Execution completed with exit code: {exit_code}")
|
|
116
|
+
logger.info(f"Execution completed with exit code: {exit_code}")
|
|
117
|
+
return exit_code
|