|
@@ -4,6 +4,7 @@
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import argparse
|
|
import argparse
|
|
|
|
|
+import base64
|
|
|
import datetime as dt
|
|
import datetime as dt
|
|
|
import hashlib
|
|
import hashlib
|
|
|
import json
|
|
import json
|
|
@@ -13,7 +14,7 @@ import shutil
|
|
|
import subprocess
|
|
import subprocess
|
|
|
import sys
|
|
import sys
|
|
|
import time
|
|
import time
|
|
|
-from typing import Any, Sequence
|
|
|
|
|
|
|
+from typing import Any, Callable, Sequence
|
|
|
import xml.etree.ElementTree as ET
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
|
|
|
|
|
|
@@ -33,6 +34,13 @@ SCENARIO_ROOT = SCRIPT_DIR / "scenarios"
|
|
|
RUN_ROOT = PROJECT_ROOT / ".debug-runs"
|
|
RUN_ROOT = PROJECT_ROOT / ".debug-runs"
|
|
|
FIXTURE_ROOT = PROJECT_ROOT / "app" / "src" / "debug" / "assets" / "debug-fixtures"
|
|
FIXTURE_ROOT = PROJECT_ROOT / "app" / "src" / "debug" / "assets" / "debug-fixtures"
|
|
|
|
|
|
|
|
|
|
+# Capability registries. New bug scenarios extend the tool by registering an
|
|
|
|
|
+# entry / action / assertion below, never by editing execute() or
|
|
|
|
|
+# validate_scenario(). See the classes near the end of this module.
|
|
|
|
|
+ENTRIES: dict[str, "Entry"] = {}
|
|
|
|
|
+ACTIONS: dict[str, "Action"] = {}
|
|
|
|
|
+ASSERTIONS: dict[str, Callable[["Context"], bool]] = {}
|
|
|
|
|
+
|
|
|
|
|
|
|
|
class ScenarioError(ValueError):
|
|
class ScenarioError(ValueError):
|
|
|
pass
|
|
pass
|
|
@@ -78,37 +86,52 @@ def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
|
|
|
timeout = scenario.get("readyTimeoutSeconds", 30)
|
|
timeout = scenario.get("readyTimeoutSeconds", 30)
|
|
|
if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
|
|
if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
|
|
|
raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
|
|
raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
|
|
|
|
|
+ change_timeout = scenario.get("contentChangeTimeoutSeconds", 5)
|
|
|
|
|
+ if not isinstance(change_timeout, int) or change_timeout < 1 or change_timeout > 30:
|
|
|
|
|
+ raise ScenarioError("contentChangeTimeoutSeconds must be between 1 and 30")
|
|
|
|
|
+ record = scenario.get("record", False)
|
|
|
|
|
+ if not isinstance(record, bool):
|
|
|
|
|
+ raise ScenarioError("record must be a boolean")
|
|
|
|
|
+ validate_preferences(scenario.get("preferences"))
|
|
|
entry = scenario.get("entry")
|
|
entry = scenario.get("entry")
|
|
|
- if not isinstance(entry, dict) or entry.get("type") != "reader":
|
|
|
|
|
- raise ScenarioError("entry.type must be reader")
|
|
|
|
|
|
|
+ if not isinstance(entry, dict) or entry.get("type") not in ENTRIES:
|
|
|
|
|
+ raise ScenarioError(f"entry.type must be one of: {', '.join(sorted(ENTRIES))}")
|
|
|
|
|
+ entry_handler = ENTRIES[entry["type"]]
|
|
|
|
|
|
|
|
actions = scenario.get("actions")
|
|
actions = scenario.get("actions")
|
|
|
if not isinstance(actions, list) or not actions:
|
|
if not isinstance(actions, list) or not actions:
|
|
|
raise ScenarioError("actions must be a non-empty array")
|
|
raise ScenarioError("actions must be a non-empty array")
|
|
|
for index, action in enumerate(actions):
|
|
for index, action in enumerate(actions):
|
|
|
- if not isinstance(action, dict) or action.get("type") != "turn_page":
|
|
|
|
|
|
|
+ if not isinstance(action, dict):
|
|
|
|
|
+ raise ScenarioError(f"actions[{index}] must be an object")
|
|
|
|
|
+ action_type = action.get("type")
|
|
|
|
|
+ if action_type not in entry_handler.allowed_actions:
|
|
|
raise ScenarioError(f"actions[{index}] has an unsupported type")
|
|
raise ScenarioError(f"actions[{index}] has an unsupported type")
|
|
|
- if action["type"] == "turn_page":
|
|
|
|
|
- if action.get("direction") not in {"next", "previous"}:
|
|
|
|
|
- raise ScenarioError(f"actions[{index}].direction must be next or previous")
|
|
|
|
|
- count = action.get("count", 1)
|
|
|
|
|
- if not isinstance(count, int) or count < 1 or count > 100:
|
|
|
|
|
- raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
|
|
|
|
|
|
|
+ ACTIONS[action_type].validate(action, index)
|
|
|
|
|
|
|
|
assertions = scenario.get("assertions")
|
|
assertions = scenario.get("assertions")
|
|
|
- supported = {
|
|
|
|
|
- "reader_ready",
|
|
|
|
|
- "reader_activity_visible",
|
|
|
|
|
- "reader_content_changed",
|
|
|
|
|
- "no_fatal_exception",
|
|
|
|
|
- }
|
|
|
|
|
if not isinstance(assertions, list) or not assertions:
|
|
if not isinstance(assertions, list) or not assertions:
|
|
|
raise ScenarioError("assertions must be a non-empty array")
|
|
raise ScenarioError("assertions must be a non-empty array")
|
|
|
- unknown = set(assertions) - supported
|
|
|
|
|
|
|
+ unknown = set(assertions) - set(ASSERTIONS)
|
|
|
if unknown:
|
|
if unknown:
|
|
|
raise ScenarioError(f"unsupported assertions: {', '.join(sorted(unknown))}")
|
|
raise ScenarioError(f"unsupported assertions: {', '.join(sorted(unknown))}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def validate_preferences(preferences: Any) -> None:
|
|
|
|
|
+ # Scenario-declared app settings applied before launch (theme engine,
|
|
|
|
|
+ # reader toggles, ...). Restricted to String/Boolean so the debug Activity
|
|
|
|
|
+ # can apply them unambiguously via AppConfigStore.
|
|
|
|
|
+ if preferences is None:
|
|
|
|
|
+ return
|
|
|
|
|
+ if not isinstance(preferences, dict) or not preferences:
|
|
|
|
|
+ raise ScenarioError("preferences must be a non-empty object when present")
|
|
|
|
|
+ for key, value in preferences.items():
|
|
|
|
|
+ if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9]*", key):
|
|
|
|
|
+ raise ScenarioError(f"invalid preference key: {key}")
|
|
|
|
|
+ if not isinstance(value, (str, bool)):
|
|
|
|
|
+ raise ScenarioError(f"preference {key} must be a string or boolean")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def validate_fixture(fixture_id: str) -> dict[str, Any]:
|
|
def validate_fixture(fixture_id: str) -> dict[str, Any]:
|
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture_id):
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture_id):
|
|
|
raise ScenarioError("fixture must be a lowercase ID")
|
|
raise ScenarioError("fixture must be a lowercase ID")
|
|
@@ -281,10 +304,26 @@ def install_apk(device: str, apk: Path, run_dir: Path) -> str:
|
|
|
raise RuntimeError(f"APK install failed after {len(attempts)} attempts: {last_error}")
|
|
raise RuntimeError(f"APK install failed after {len(attempts)} attempts: {last_error}")
|
|
|
|
|
|
|
|
|
|
|
|
|
-def launch_fixture(device: str, fixture: str, session_id: str) -> None:
|
|
|
|
|
|
|
+def launch_fixture(
|
|
|
|
|
+ device: str,
|
|
|
|
|
+ fixture: str,
|
|
|
|
|
+ entry: str,
|
|
|
|
|
+ session_id: str,
|
|
|
|
|
+ preferences: dict[str, Any] | None = None,
|
|
|
|
|
+) -> None:
|
|
|
adb(device, "shell", "am", "force-stop", PACKAGE)
|
|
adb(device, "shell", "am", "force-stop", PACKAGE)
|
|
|
adb(device, "logcat", "-c")
|
|
adb(device, "logcat", "-c")
|
|
|
component = f"{PACKAGE}/{DEBUG_ACTIVITY}"
|
|
component = f"{PACKAGE}/{DEBUG_ACTIVITY}"
|
|
|
|
|
+ extras = [
|
|
|
|
|
+ "--es", "fixture", fixture,
|
|
|
|
|
+ "--es", "session", session_id,
|
|
|
|
|
+ "--es", "entry", entry,
|
|
|
|
|
+ ]
|
|
|
|
|
+ if preferences:
|
|
|
|
|
+ # base64 so the JSON survives `adb shell am` re-parsing on the device
|
|
|
|
|
+ # (raw JSON gets quote-stripped and brace-expanded by the device shell).
|
|
|
|
|
+ payload = json.dumps(preferences, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
+ extras += ["--es", "preferencesB64", base64.b64encode(payload).decode("ascii")]
|
|
|
result = adb(
|
|
result = adb(
|
|
|
device,
|
|
device,
|
|
|
"shell",
|
|
"shell",
|
|
@@ -293,12 +332,7 @@ def launch_fixture(device: str, fixture: str, session_id: str) -> None:
|
|
|
"-W",
|
|
"-W",
|
|
|
"-n",
|
|
"-n",
|
|
|
component,
|
|
component,
|
|
|
- "--es",
|
|
|
|
|
- "fixture",
|
|
|
|
|
- fixture,
|
|
|
|
|
- "--es",
|
|
|
|
|
- "session",
|
|
|
|
|
- session_id,
|
|
|
|
|
|
|
+ *extras,
|
|
|
timeout=60,
|
|
timeout=60,
|
|
|
check=False,
|
|
check=False,
|
|
|
)
|
|
)
|
|
@@ -310,39 +344,198 @@ def ui_dump(device: str) -> str:
|
|
|
return adb(device, "exec-out", "uiautomator", "dump", "/dev/tty", timeout=20, check=False).stdout
|
|
return adb(device, "exec-out", "uiautomator", "dump", "/dev/tty", timeout=20, check=False).stdout
|
|
|
|
|
|
|
|
|
|
|
|
|
-def current_focus(device: str) -> str:
|
|
|
|
|
|
|
+def window_state(device: str) -> str:
|
|
|
|
|
+ # Full `dumpsys window windows` output. Callers substring-match against it,
|
|
|
|
|
+ # so a hit means the activity is present in the window stack, not strictly
|
|
|
|
|
+ # the foreground window.
|
|
|
return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
|
|
return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
|
|
|
|
|
|
|
|
|
|
|
|
|
-def extract_reader_content(dump: str) -> str:
|
|
|
|
|
|
|
+def device_is_awake(device: str) -> bool:
|
|
|
|
|
+ output = adb(device, "shell", "dumpsys", "power", timeout=20, check=False).stdout
|
|
|
|
|
+ return "mWakefulness=Awake" in output
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def keyguard_is_showing(device: str) -> bool:
|
|
|
|
|
+ output = adb(device, "shell", "dumpsys", "window", "policy", timeout=20, check=False).stdout
|
|
|
|
|
+ match = re.search(r"^\s+showing=(true|false)\b", output, re.MULTILINE)
|
|
|
|
|
+ return match is not None and match.group(1) == "true"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def device_ready_for_ui(device: str) -> bool:
|
|
|
|
|
+ return device_is_awake(device) and not keyguard_is_showing(device)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def prepare_device(device: str) -> dict[str, Any]:
|
|
|
|
|
+ state = {
|
|
|
|
|
+ "wasAwake": device_is_awake(device),
|
|
|
|
|
+ "wasKeyguardShowing": keyguard_is_showing(device),
|
|
|
|
|
+ "stayOnWhilePluggedIn": adb(
|
|
|
|
|
+ device,
|
|
|
|
|
+ "shell",
|
|
|
|
|
+ "settings",
|
|
|
|
|
+ "get",
|
|
|
|
|
+ "global",
|
|
|
|
|
+ "stay_on_while_plugged_in",
|
|
|
|
|
+ timeout=20,
|
|
|
|
|
+ check=False,
|
|
|
|
|
+ ).stdout.strip(),
|
|
|
|
|
+ }
|
|
|
|
|
+ adb(
|
|
|
|
|
+ device,
|
|
|
|
|
+ "shell",
|
|
|
|
|
+ "settings",
|
|
|
|
|
+ "put",
|
|
|
|
|
+ "global",
|
|
|
|
|
+ "stay_on_while_plugged_in",
|
|
|
|
|
+ "2",
|
|
|
|
|
+ timeout=20,
|
|
|
|
|
+ )
|
|
|
|
|
+ if not state["wasAwake"]:
|
|
|
|
|
+ adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20)
|
|
|
|
|
+ if keyguard_is_showing(device):
|
|
|
|
|
+ adb(device, "shell", "wm", "dismiss-keyguard", timeout=20, check=False)
|
|
|
|
|
+ time.sleep(0.2)
|
|
|
|
|
+ if keyguard_is_showing(device):
|
|
|
|
|
+ adb(device, "shell", "input", "keyevent", "82", timeout=20, check=False)
|
|
|
|
|
+ time.sleep(0.5)
|
|
|
|
|
+ if not device_ready_for_ui(device):
|
|
|
|
|
+ restore_device(device, state)
|
|
|
|
|
+ raise RuntimeError("device could not be awakened and unlocked for UI automation")
|
|
|
|
|
+ return state
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def restore_device(device: str, state: dict[str, Any] | None) -> None:
|
|
|
|
|
+ if state is None:
|
|
|
|
|
+ return
|
|
|
|
|
+ stay_on = state.get("stayOnWhilePluggedIn")
|
|
|
|
|
+ if stay_on in {"", "null", None}:
|
|
|
|
|
+ adb(
|
|
|
|
|
+ device,
|
|
|
|
|
+ "shell",
|
|
|
|
|
+ "settings",
|
|
|
|
|
+ "delete",
|
|
|
|
|
+ "global",
|
|
|
|
|
+ "stay_on_while_plugged_in",
|
|
|
|
|
+ timeout=20,
|
|
|
|
|
+ check=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ adb(
|
|
|
|
|
+ device,
|
|
|
|
|
+ "shell",
|
|
|
|
|
+ "settings",
|
|
|
|
|
+ "put",
|
|
|
|
|
+ "global",
|
|
|
|
|
+ "stay_on_while_plugged_in",
|
|
|
|
|
+ str(stay_on),
|
|
|
|
|
+ timeout=20,
|
|
|
|
|
+ check=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ if state.get("wasKeyguardShowing"):
|
|
|
|
|
+ adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
|
|
|
|
|
+ if state.get("wasAwake"):
|
|
|
|
|
+ adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20, check=False)
|
|
|
|
|
+ elif not state.get("wasAwake"):
|
|
|
|
|
+ adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def parse_ui_nodes(dump: str) -> list[dict[str, str]]:
|
|
|
start = dump.find("<?xml")
|
|
start = dump.find("<?xml")
|
|
|
end = dump.rfind("</hierarchy>")
|
|
end = dump.rfind("</hierarchy>")
|
|
|
if start < 0 or end < 0:
|
|
if start < 0 or end < 0:
|
|
|
- return ""
|
|
|
|
|
|
|
+ return []
|
|
|
try:
|
|
try:
|
|
|
root = ET.fromstring(dump[start : end + len("</hierarchy>")])
|
|
root = ET.fromstring(dump[start : end + len("</hierarchy>")])
|
|
|
except ET.ParseError:
|
|
except ET.ParseError:
|
|
|
- return ""
|
|
|
|
|
- contents = [
|
|
|
|
|
- node.attrib.get("content-desc", "")
|
|
|
|
|
- for node in root.iter("node")
|
|
|
|
|
- if node.attrib.get("package") == PACKAGE
|
|
|
|
|
- and node.attrib.get("class") == "io.legado.app.ui.book.read.page.ReadView"
|
|
|
|
|
|
|
+ return []
|
|
|
|
|
+ return [node.attrib for node in root.iter("node")]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+READER_PAGE_TAG = "READER_PAGE "
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def extract_reader_pages(logcat: str) -> list[str]:
|
|
|
|
|
+ # ReadView logs "READER_PAGE <one-line page text>" on every render (debug
|
|
|
|
|
+ # build only). This is the device-independent reader signal — uiautomator
|
|
|
|
|
+ # does not expose the ReadView node on all devices.
|
|
|
|
|
+ pages = []
|
|
|
|
|
+ for line in logcat.splitlines():
|
|
|
|
|
+ index = line.find(READER_PAGE_TAG)
|
|
|
|
|
+ if index >= 0:
|
|
|
|
|
+ pages.append(line[index + len(READER_PAGE_TAG):].strip())
|
|
|
|
|
+ return pages
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def reader_current_page(device: str) -> str:
|
|
|
|
|
+ logcat = adb(device, "logcat", "-d", "-s", "LegadoDebug:I", timeout=20, check=False).stdout
|
|
|
|
|
+ pages = extract_reader_pages(logcat)
|
|
|
|
|
+ return pages[-1] if pages else ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def find_node_by_content_prefix(dump: str, prefix: str) -> dict[str, str] | None:
|
|
|
|
|
+ # The bookshelf item's label lives on a node whose clickable flag is false
|
|
|
|
|
+ # (the clickable modifier sits on an ancestor). Match by content-desc and
|
|
|
|
|
+ # tap the label's center; the clickable ancestor is under that point.
|
|
|
|
|
+ candidates = [
|
|
|
|
|
+ node
|
|
|
|
|
+ for node in parse_ui_nodes(dump)
|
|
|
|
|
+ if node.get("package") == PACKAGE
|
|
|
|
|
+ and node.get("content-desc", "").startswith(prefix)
|
|
|
]
|
|
]
|
|
|
- return max(contents, key=len, default="")
|
|
|
|
|
|
|
+ return candidates[0] if candidates else None
|
|
|
|
|
|
|
|
|
|
|
|
|
-def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
|
|
|
|
|
|
|
+def node_center(node: dict[str, str]) -> tuple[int, int]:
|
|
|
|
|
+ bounds = node.get("bounds", "")
|
|
|
|
|
+ match = re.fullmatch(r"\[(\d+),(\d+)]\[(\d+),(\d+)]", bounds)
|
|
|
|
|
+ if match is None:
|
|
|
|
|
+ raise RuntimeError(f"invalid accessibility bounds: {bounds}")
|
|
|
|
|
+ left, top, right, bottom = map(int, match.groups())
|
|
|
|
|
+ return (left + right) // 2, (top + bottom) // 2
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def wait_until_node_present(
|
|
|
|
|
+ device: str,
|
|
|
|
|
+ content_prefix: str,
|
|
|
|
|
+ timeout_seconds: int,
|
|
|
|
|
+) -> tuple[bool, str, dict[str, str] | None]:
|
|
|
deadline = time.monotonic() + timeout_seconds
|
|
deadline = time.monotonic() + timeout_seconds
|
|
|
latest = ""
|
|
latest = ""
|
|
|
- content = ""
|
|
|
|
|
|
|
+ node = None
|
|
|
while time.monotonic() < deadline:
|
|
while time.monotonic() < deadline:
|
|
|
latest = ui_dump(device)
|
|
latest = ui_dump(device)
|
|
|
- content = extract_reader_content(latest)
|
|
|
|
|
- if marker in content and PACKAGE in current_focus(device):
|
|
|
|
|
- return True, latest, content
|
|
|
|
|
|
|
+ node = find_node_by_content_prefix(latest, content_prefix)
|
|
|
|
|
+ if node is not None and device_ready_for_ui(device):
|
|
|
|
|
+ return True, latest, node
|
|
|
|
|
+ time.sleep(0.5)
|
|
|
|
|
+ return False, latest, node
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
|
|
|
|
|
+ deadline = time.monotonic() + timeout_seconds
|
|
|
|
|
+ content = ""
|
|
|
|
|
+ while time.monotonic() < deadline:
|
|
|
|
|
+ content = reader_current_page(device)
|
|
|
|
|
+ if marker in content and PACKAGE in window_state(device) and device_ready_for_ui(device):
|
|
|
|
|
+ return True, content, content
|
|
|
time.sleep(0.5)
|
|
time.sleep(0.5)
|
|
|
- return False, latest, content
|
|
|
|
|
|
|
+ return False, content, content
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def wait_until_reader_content_changed(
|
|
|
|
|
+ device: str,
|
|
|
|
|
+ previous_content: str,
|
|
|
|
|
+ timeout_seconds: int,
|
|
|
|
|
+) -> tuple[bool, str, str]:
|
|
|
|
|
+ deadline = time.monotonic() + timeout_seconds
|
|
|
|
|
+ content = ""
|
|
|
|
|
+ while time.monotonic() < deadline:
|
|
|
|
|
+ content = reader_current_page(device)
|
|
|
|
|
+ if content and content != previous_content:
|
|
|
|
|
+ return True, content, content
|
|
|
|
|
+ time.sleep(0.2)
|
|
|
|
|
+ return False, content, content
|
|
|
|
|
|
|
|
|
|
|
|
|
def application_pid(device: str) -> str | None:
|
|
def application_pid(device: str) -> str | None:
|
|
@@ -373,53 +566,94 @@ def capture_screenshot(device: str, path: Path) -> None:
|
|
|
raise RuntimeError(result.stderr.decode(errors="replace"))
|
|
raise RuntimeError(result.stderr.decode(errors="replace"))
|
|
|
|
|
|
|
|
|
|
|
|
|
-def perform_actions(device: str, scenario: dict[str, Any], action_repeat: int) -> int:
|
|
|
|
|
- width, height = screen_size(device)
|
|
|
|
|
- performed = 0
|
|
|
|
|
- for action in scenario["actions"]:
|
|
|
|
|
- direction = action["direction"]
|
|
|
|
|
- count = action.get("count", 1) * action_repeat
|
|
|
|
|
- x = round(width * (0.84 if direction == "next" else 0.16))
|
|
|
|
|
- y = round(height * 0.5)
|
|
|
|
|
- for _ in range(count):
|
|
|
|
|
- adb(device, "shell", "input", "tap", str(x), str(y), timeout=10)
|
|
|
|
|
- performed += 1
|
|
|
|
|
- time.sleep(0.4)
|
|
|
|
|
- return performed
|
|
|
|
|
|
|
+class ScreenRecorder:
|
|
|
|
|
+ """Optional screenrecord wrapper for capturing transient UI (animations).
|
|
|
|
|
+
|
|
|
|
|
+ Best-effort: any failure to start, stop, or pull leaves the run unaffected
|
|
|
|
|
+ and simply produces no video artifact.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ REMOTE = "/sdcard/legado-debug-transition.mp4"
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, device: str):
|
|
|
|
|
+ self.device = device
|
|
|
|
|
+ self.process: subprocess.Popen[bytes] | None = None
|
|
|
|
|
+ self.saved: str | None = None
|
|
|
|
|
+ self._stopped = False
|
|
|
|
|
+
|
|
|
|
|
+ def start(self) -> None:
|
|
|
|
|
+ adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
|
|
|
|
|
+ self.process = subprocess.Popen(
|
|
|
|
|
+ ["adb", "-s", self.device, "shell", "screenrecord", "--time-limit", "60", self.REMOTE],
|
|
|
|
|
+ cwd=PROJECT_ROOT,
|
|
|
|
|
+ stdout=subprocess.DEVNULL,
|
|
|
|
|
+ stderr=subprocess.DEVNULL,
|
|
|
|
|
+ )
|
|
|
|
|
+ time.sleep(0.5)
|
|
|
|
|
+
|
|
|
|
|
+ def stop(self, run_dir: Path, name: str = "transition.mp4") -> str | None:
|
|
|
|
|
+ if self.process is None or self._stopped:
|
|
|
|
|
+ return self.saved
|
|
|
|
|
+ self._stopped = True
|
|
|
|
|
+ # SIGINT lets screenrecord finalize a playable mp4 before it exits.
|
|
|
|
|
+ pids = adb(self.device, "shell", "pidof", "screenrecord", timeout=10, check=False).stdout.split()
|
|
|
|
|
+ for pid in pids:
|
|
|
|
|
+ adb(self.device, "shell", "kill", "-INT", pid, timeout=10, check=False)
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.process.wait(timeout=15)
|
|
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
|
|
+ self.process.kill()
|
|
|
|
|
+ pull = subprocess.run(
|
|
|
|
|
+ ["adb", "-s", self.device, "pull", self.REMOTE, str(run_dir / name)],
|
|
|
|
|
+ cwd=PROJECT_ROOT,
|
|
|
|
|
+ capture_output=True,
|
|
|
|
|
+ text=True,
|
|
|
|
|
+ timeout=60,
|
|
|
|
|
+ )
|
|
|
|
|
+ adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
|
|
|
|
|
+ if pull.returncode == 0 and (run_dir / name).is_file():
|
|
|
|
|
+ self.saved = name
|
|
|
|
|
+ return self.saved
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect_logcat(
|
|
def collect_logcat(
|
|
|
device: str,
|
|
device: str,
|
|
|
run_dir: Path,
|
|
run_dir: Path,
|
|
|
- app_pid: str | None = None,
|
|
|
|
|
|
|
+ app_pids: Sequence[str] = (),
|
|
|
session_id: str | None = None,
|
|
session_id: str | None = None,
|
|
|
) -> str:
|
|
) -> str:
|
|
|
logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
|
|
logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
|
|
|
(run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
|
|
(run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
|
|
|
session_logcat = ""
|
|
session_logcat = ""
|
|
|
- if app_pid:
|
|
|
|
|
- session_logcat = adb(
|
|
|
|
|
|
|
+ unique_pids = list(dict.fromkeys(pid for pid in app_pids if pid))
|
|
|
|
|
+ for pid in unique_pids:
|
|
|
|
|
+ pid_logcat = adb(
|
|
|
device,
|
|
device,
|
|
|
"logcat",
|
|
"logcat",
|
|
|
"-d",
|
|
"-d",
|
|
|
- f"--pid={app_pid}",
|
|
|
|
|
|
|
+ f"--pid={pid}",
|
|
|
"-v",
|
|
"-v",
|
|
|
"threadtime",
|
|
"threadtime",
|
|
|
timeout=45,
|
|
timeout=45,
|
|
|
check=False,
|
|
check=False,
|
|
|
).stdout
|
|
).stdout
|
|
|
|
|
+ session_logcat += f"--- pid={pid} ---\n{pid_logcat}"
|
|
|
if session_id:
|
|
if session_id:
|
|
|
session_lines = [line for line in logcat.splitlines() if f"[session={session_id}]" in line]
|
|
session_lines = [line for line in logcat.splitlines() if f"[session={session_id}]" in line]
|
|
|
for line in session_lines:
|
|
for line in session_lines:
|
|
|
if line not in session_logcat:
|
|
if line not in session_logcat:
|
|
|
session_logcat += line + "\n"
|
|
session_logcat += line + "\n"
|
|
|
(run_dir / "session-logcat.txt").write_text(session_logcat, encoding="utf-8")
|
|
(run_dir / "session-logcat.txt").write_text(session_logcat, encoding="utf-8")
|
|
|
- crash = extract_crash(logcat, PACKAGE, app_pid)
|
|
|
|
|
|
|
+ crash = extract_crash(logcat, PACKAGE, unique_pids)
|
|
|
(run_dir / "crash.txt").write_text(crash, encoding="utf-8")
|
|
(run_dir / "crash.txt").write_text(crash, encoding="utf-8")
|
|
|
return logcat
|
|
return logcat
|
|
|
|
|
|
|
|
|
|
|
|
|
-def extract_crash(logcat: str, package: str = PACKAGE, app_pid: str | None = None) -> str:
|
|
|
|
|
|
|
+def extract_crash(
|
|
|
|
|
+ logcat: str,
|
|
|
|
|
+ package: str = PACKAGE,
|
|
|
|
|
+ app_pids: Sequence[str] = (),
|
|
|
|
|
+) -> str:
|
|
|
lines = logcat.splitlines()
|
|
lines = logcat.splitlines()
|
|
|
chunks = []
|
|
chunks = []
|
|
|
for index, line in enumerate(lines):
|
|
for index, line in enumerate(lines):
|
|
@@ -428,7 +662,9 @@ def extract_crash(logcat: str, package: str = PACKAGE, app_pid: str | None = Non
|
|
|
chunks.extend(chunk)
|
|
chunks.extend(chunk)
|
|
|
elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
|
|
elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
|
|
|
chunks.extend(chunk)
|
|
chunks.extend(chunk)
|
|
|
- elif "Fatal signal " in line and app_pid and re.search(rf"\b{re.escape(app_pid)}\b", line):
|
|
|
|
|
|
|
+ elif "Fatal signal " in line and any(
|
|
|
|
|
+ re.search(rf"\b{re.escape(pid)}\b", line) for pid in app_pids
|
|
|
|
|
+ ):
|
|
|
chunks.extend(chunk)
|
|
chunks.extend(chunk)
|
|
|
if not chunks:
|
|
if not chunks:
|
|
|
return ""
|
|
return ""
|
|
@@ -505,6 +741,28 @@ def make_summary(
|
|
|
"reader_content_changed": content_changed,
|
|
"reader_content_changed": content_changed,
|
|
|
"no_fatal_exception": failure is None,
|
|
"no_fatal_exception": failure is None,
|
|
|
}
|
|
}
|
|
|
|
|
+ return make_summary_from_assertions(
|
|
|
|
|
+ session_id=session_id,
|
|
|
|
|
+ scenario=scenario,
|
|
|
|
|
+ fixture=fixture,
|
|
|
|
|
+ build=build,
|
|
|
|
|
+ assertion_values=assertion_values,
|
|
|
|
|
+ action_count=action_count,
|
|
|
|
|
+ failure=failure,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def make_summary_from_assertions(
|
|
|
|
|
+ *,
|
|
|
|
|
+ session_id: str,
|
|
|
|
|
+ scenario: dict[str, Any],
|
|
|
|
|
+ fixture: str,
|
|
|
|
|
+ build: dict[str, Any],
|
|
|
|
|
+ assertion_values: dict[str, bool],
|
|
|
|
|
+ action_count: int,
|
|
|
|
|
+ failure: dict[str, Any] | None,
|
|
|
|
|
+ extra_artifacts: dict[str, str] | None = None,
|
|
|
|
|
+) -> tuple[dict[str, Any], int]:
|
|
|
assertions = [
|
|
assertions = [
|
|
|
{"name": name, "passed": assertion_values[name]}
|
|
{"name": name, "passed": assertion_values[name]}
|
|
|
for name in scenario["assertions"]
|
|
for name in scenario["assertions"]
|
|
@@ -530,12 +788,12 @@ def make_summary(
|
|
|
"logcat": "logcat.txt",
|
|
"logcat": "logcat.txt",
|
|
|
"sessionLogcat": "session-logcat.txt",
|
|
"sessionLogcat": "session-logcat.txt",
|
|
|
"crash": "crash.txt",
|
|
"crash": "crash.txt",
|
|
|
- "beforeScreenshot": "before.png",
|
|
|
|
|
- "afterScreenshot": "after.png",
|
|
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
if failure is not None:
|
|
if failure is not None:
|
|
|
summary["failure"] = failure
|
|
summary["failure"] = failure
|
|
|
|
|
+ if extra_artifacts:
|
|
|
|
|
+ summary["artifacts"].update(extra_artifacts)
|
|
|
return summary, exit_code
|
|
return summary, exit_code
|
|
|
|
|
|
|
|
|
|
|
|
@@ -567,6 +825,277 @@ def cleanup_device(device: str) -> None:
|
|
|
adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
|
|
adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# --------------------------------------------------------------------------- #
|
|
|
|
|
+# Capability registry: entries, actions, assertions.
|
|
|
|
|
+#
|
|
|
|
|
+# A scenario is fully described by data (JSON). Each domain action and each
|
|
|
|
|
+# assertion is a small handler registered here. Adding a bug scenario means
|
|
|
|
|
+# adding a handler + a JSON file, not editing execute() / validate_scenario().
|
|
|
|
|
+# Handlers only *record* raw observations into the Context; assertions are pure
|
|
|
|
|
+# reads of those observations.
|
|
|
|
|
+# --------------------------------------------------------------------------- #
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Context:
|
|
|
|
|
+ """Mutable per-run state shared across entry, actions, and assertions."""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(
|
|
|
|
|
+ self,
|
|
|
|
|
+ device: str,
|
|
|
|
|
+ run_dir: Path,
|
|
|
|
|
+ scenario: dict[str, Any],
|
|
|
|
|
+ fixture_data: dict[str, Any],
|
|
|
|
|
+ metadata: dict[str, Any],
|
|
|
|
|
+ action_repeat: int,
|
|
|
|
|
+ ):
|
|
|
|
|
+ self.device = device
|
|
|
|
|
+ self.run_dir = run_dir
|
|
|
|
|
+ self.scenario = scenario
|
|
|
|
|
+ self.fixture_data = fixture_data
|
|
|
|
|
+ self.metadata = metadata
|
|
|
|
|
+ self.action_repeat = action_repeat
|
|
|
|
|
+ self.app_pids: list[str] = []
|
|
|
|
|
+ self.records: dict[str, Any] = {}
|
|
|
|
|
+ self.state: dict[str, Any] = {}
|
|
|
|
|
+ self.artifacts: dict[str, str] = {}
|
|
|
|
|
+ self.action_count = 0
|
|
|
|
|
+ self.assertion_values: dict[str, bool] = {}
|
|
|
|
|
+ self.failure: dict[str, Any] | None = None
|
|
|
|
|
+
|
|
|
|
|
+ def note_pid(self) -> None:
|
|
|
|
|
+ pid = application_pid(self.device)
|
|
|
|
|
+ if pid and pid not in self.app_pids:
|
|
|
|
|
+ self.app_pids.append(pid)
|
|
|
|
|
+
|
|
|
|
|
+ def screenshot(self, name: str, filename: str) -> None:
|
|
|
|
|
+ capture_screenshot(self.device, self.run_dir / filename)
|
|
|
|
|
+ self.artifacts[name] = filename
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Entry:
|
|
|
|
|
+ type: str = ""
|
|
|
|
|
+ allowed_actions: set[str] = set()
|
|
|
|
|
+
|
|
|
|
|
+ def prepare(self, ctx: Context) -> None:
|
|
|
|
|
+ raise NotImplementedError
|
|
|
|
|
+
|
|
|
|
|
+ def finalize(self, ctx: Context) -> None:
|
|
|
|
|
+ raise NotImplementedError
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Action:
|
|
|
|
|
+ name: str = ""
|
|
|
|
|
+ params: dict[str, Any] = {}
|
|
|
|
|
+
|
|
|
|
|
+ def validate(self, action: dict[str, Any], index: int) -> None:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ def run(self, ctx: Context, action: dict[str, Any]) -> None:
|
|
|
|
|
+ raise NotImplementedError
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def register_entry(name: str) -> Callable[[type], type]:
|
|
|
|
|
+ def decorate(cls: type) -> type:
|
|
|
|
|
+ instance = cls()
|
|
|
|
|
+ instance.type = name
|
|
|
|
|
+ ENTRIES[name] = instance
|
|
|
|
|
+ return cls
|
|
|
|
|
+
|
|
|
|
|
+ return decorate
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def register_action(name: str) -> Callable[[type], type]:
|
|
|
|
|
+ def decorate(cls: type) -> type:
|
|
|
|
|
+ instance = cls()
|
|
|
|
|
+ instance.name = name
|
|
|
|
|
+ ACTIONS[name] = instance
|
|
|
|
|
+ return cls
|
|
|
|
|
+
|
|
|
|
|
+ return decorate
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@register_entry("reader")
|
|
|
|
|
+class ReaderEntry(Entry):
|
|
|
|
|
+ allowed_actions = {"turn_page"}
|
|
|
|
|
+
|
|
|
|
|
+ def prepare(self, ctx: Context) -> None:
|
|
|
|
|
+ ready, before_dump, before_content = wait_until_reader_ready(
|
|
|
|
|
+ ctx.device,
|
|
|
|
|
+ ctx.scenario["readyMarker"],
|
|
|
|
|
+ ctx.scenario.get("readyTimeoutSeconds", 30),
|
|
|
|
|
+ )
|
|
|
|
|
+ ctx.note_pid()
|
|
|
|
|
+ ctx.screenshot("beforeScreenshot", "before.png")
|
|
|
|
|
+ ctx.records["reader_ready"] = ready
|
|
|
|
|
+ ctx.records["reader_content_changed"] = False
|
|
|
|
|
+ ctx.records["content_change_duration_ms"] = 0
|
|
|
|
|
+ ctx.records["ui_before"] = before_dump
|
|
|
|
|
+ ctx.records["ui_after"] = before_dump
|
|
|
|
|
+ ctx.records["reader_content_before"] = before_content
|
|
|
|
|
+ ctx.records["reader_content_after"] = before_content
|
|
|
|
|
+ ctx.state["reader_content"] = before_content
|
|
|
|
|
+
|
|
|
|
|
+ def finalize(self, ctx: Context) -> None:
|
|
|
|
|
+ ctx.screenshot("afterScreenshot", "after.png")
|
|
|
|
|
+ windows = window_state(ctx.device)
|
|
|
|
|
+ # Presence of MainActivity in the window stack; the reader runs as a
|
|
|
|
|
+ # route inside MainActivity in this fork.
|
|
|
|
|
+ visible = PACKAGE in windows and MAIN_ACTIVITY in windows and device_ready_for_ui(ctx.device)
|
|
|
|
|
+ ctx.records["reader_activity_visible"] = visible
|
|
|
|
|
+ ctx.metadata.update(
|
|
|
|
|
+ {
|
|
|
|
|
+ "actionRepeat": ctx.action_repeat,
|
|
|
|
|
+ "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
|
|
|
|
|
+ "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
|
|
|
|
|
+ "readerContentBeforeSha256": sha256_text(ctx.records["reader_content_before"]),
|
|
|
|
|
+ "readerContentAfterSha256": sha256_text(ctx.records["reader_content_after"]),
|
|
|
|
|
+ "contentChangeDurationMs": ctx.records["content_change_duration_ms"],
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@register_entry("bookshelf")
|
|
|
|
|
+class BookshelfEntry(Entry):
|
|
|
|
|
+ allowed_actions = {"open_fixture_book", "press_back"}
|
|
|
|
|
+
|
|
|
|
|
+ def prepare(self, ctx: Context) -> None:
|
|
|
|
|
+ book_name = ctx.fixture_data["book"]["name"]
|
|
|
|
|
+ ready, before_dump, book_node = wait_until_node_present(
|
|
|
|
|
+ ctx.device,
|
|
|
|
|
+ book_name,
|
|
|
|
|
+ ctx.scenario.get("readyTimeoutSeconds", 30),
|
|
|
|
|
+ )
|
|
|
|
|
+ ctx.note_pid()
|
|
|
|
|
+ ctx.screenshot("beforeScreenshot", "before.png")
|
|
|
|
|
+ ctx.records["bookshelf_ready"] = ready
|
|
|
|
|
+ ctx.records["reader_opened"] = False
|
|
|
|
|
+ ctx.records["returned_to_bookshelf"] = False
|
|
|
|
|
+ ctx.records["ui_before"] = before_dump
|
|
|
|
|
+ ctx.records["ui_after"] = before_dump
|
|
|
|
|
+ ctx.records["reader_dump"] = ""
|
|
|
|
|
+ ctx.records["reader_content"] = ""
|
|
|
|
|
+ ctx.state["book_name"] = book_name
|
|
|
|
|
+ ctx.state["book_node"] = book_node
|
|
|
|
|
+ ctx.state["returned_node"] = None
|
|
|
|
|
+
|
|
|
|
|
+ def finalize(self, ctx: Context) -> None:
|
|
|
|
|
+ ctx.screenshot("afterScreenshot", "after.png")
|
|
|
|
|
+ book_node = ctx.state.get("book_node")
|
|
|
|
|
+ returned_node = ctx.state.get("returned_node")
|
|
|
|
|
+ ctx.metadata.update(
|
|
|
|
|
+ {
|
|
|
|
|
+ "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
|
|
|
|
|
+ "readerUiSha256": sha256_text(ctx.records["reader_dump"]),
|
|
|
|
|
+ "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
|
|
|
|
|
+ "readerContentSha256": sha256_text(ctx.records["reader_content"]),
|
|
|
|
|
+ "bookshelfItemBefore": book_node.get("content-desc") if book_node else None,
|
|
|
|
|
+ "bookshelfItemAfter": returned_node.get("content-desc") if returned_node else None,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@register_action("turn_page")
|
|
|
|
|
+class TurnPage(Action):
|
|
|
|
|
+ params = {"direction": ["next", "previous"], "count": "1-100"}
|
|
|
|
|
+
|
|
|
|
|
+ def validate(self, action: dict[str, Any], index: int) -> None:
|
|
|
|
|
+ if action.get("direction") not in {"next", "previous"}:
|
|
|
|
|
+ raise ScenarioError(f"actions[{index}].direction must be next or previous")
|
|
|
|
|
+ count = action.get("count", 1)
|
|
|
|
|
+ if not isinstance(count, int) or count < 1 or count > 100:
|
|
|
|
|
+ raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
|
|
|
|
|
+
|
|
|
|
|
+ def run(self, ctx: Context, action: dict[str, Any]) -> None:
|
|
|
|
|
+ if not ctx.records.get("reader_ready"):
|
|
|
|
|
+ return
|
|
|
|
|
+ width, height = screen_size(ctx.device)
|
|
|
|
|
+ direction = action["direction"]
|
|
|
|
|
+ count = action.get("count", 1) * ctx.action_repeat
|
|
|
|
|
+ x = round(width * (0.84 if direction == "next" else 0.16))
|
|
|
|
|
+ y = round(height * 0.5)
|
|
|
|
|
+ for _ in range(count):
|
|
|
|
|
+ adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
|
|
|
|
|
+ ctx.action_count += 1
|
|
|
|
|
+ time.sleep(0.4)
|
|
|
|
|
+ started = time.monotonic()
|
|
|
|
|
+ changed, after_dump, after_content = wait_until_reader_content_changed(
|
|
|
|
|
+ ctx.device,
|
|
|
|
|
+ ctx.state["reader_content"],
|
|
|
|
|
+ ctx.scenario.get("contentChangeTimeoutSeconds", 5),
|
|
|
|
|
+ )
|
|
|
|
|
+ ctx.records["content_change_duration_ms"] = round((time.monotonic() - started) * 1000)
|
|
|
|
|
+ if changed:
|
|
|
|
|
+ ctx.records["reader_content_changed"] = True
|
|
|
|
|
+ ctx.state["reader_content"] = after_content
|
|
|
|
|
+ ctx.records["reader_content_after"] = after_content
|
|
|
|
|
+ ctx.records["ui_after"] = after_dump
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@register_action("open_fixture_book")
|
|
|
|
|
+class OpenFixtureBook(Action):
|
|
|
|
|
+ def run(self, ctx: Context, action: dict[str, Any]) -> None:
|
|
|
|
|
+ book_node = ctx.state.get("book_node")
|
|
|
|
|
+ if not (ctx.records.get("bookshelf_ready") and book_node is not None):
|
|
|
|
|
+ return
|
|
|
|
|
+ x, y = node_center(book_node)
|
|
|
|
|
+ adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
|
|
|
|
|
+ ctx.action_count += 1
|
|
|
|
|
+ opened, reader_dump, reader_content = wait_until_reader_ready(
|
|
|
|
|
+ ctx.device,
|
|
|
|
|
+ ctx.scenario["readyMarker"],
|
|
|
|
|
+ ctx.scenario.get("readyTimeoutSeconds", 30),
|
|
|
|
|
+ )
|
|
|
|
|
+ ctx.records["reader_opened"] = opened
|
|
|
|
|
+ ctx.records["reader_dump"] = reader_dump
|
|
|
|
|
+ ctx.records["reader_content"] = reader_content
|
|
|
|
|
+ ctx.screenshot("readerScreenshot", "reader.png")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@register_action("press_back")
|
|
|
|
|
+class PressBack(Action):
|
|
|
|
|
+ def run(self, ctx: Context, action: dict[str, Any]) -> None:
|
|
|
|
|
+ if not ctx.records.get("reader_opened"):
|
|
|
|
|
+ return
|
|
|
|
|
+ adb(ctx.device, "shell", "input", "keyevent", "BACK", timeout=10)
|
|
|
|
|
+ ctx.action_count += 1
|
|
|
|
|
+ returned, after_dump, returned_node = wait_until_node_present(
|
|
|
|
|
+ ctx.device,
|
|
|
|
|
+ ctx.state["book_name"],
|
|
|
|
|
+ ctx.scenario.get("readyTimeoutSeconds", 30),
|
|
|
|
|
+ )
|
|
|
|
|
+ ctx.records["returned_to_bookshelf"] = returned
|
|
|
|
|
+ ctx.records["ui_after"] = after_dump
|
|
|
|
|
+ ctx.state["returned_node"] = returned_node
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ASSERTIONS.update(
|
|
|
|
|
+ {
|
|
|
|
|
+ "reader_ready": lambda ctx: bool(ctx.records.get("reader_ready")),
|
|
|
|
|
+ "reader_activity_visible": lambda ctx: bool(ctx.records.get("reader_activity_visible")),
|
|
|
|
|
+ "reader_content_changed": lambda ctx: bool(ctx.records.get("reader_content_changed")),
|
|
|
|
|
+ "bookshelf_ready": lambda ctx: bool(ctx.records.get("bookshelf_ready")),
|
|
|
|
|
+ "reader_opened": lambda ctx: bool(ctx.records.get("reader_opened")),
|
|
|
|
|
+ "returned_to_bookshelf": lambda ctx: bool(ctx.records.get("returned_to_bookshelf")),
|
|
|
|
|
+ "no_fatal_exception": lambda ctx: ctx.failure is None,
|
|
|
|
|
+ }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_capabilities() -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schemaVersion": 1,
|
|
|
|
|
+ "entries": {
|
|
|
|
|
+ name: {"allowedActions": sorted(handler.allowed_actions)}
|
|
|
|
|
+ for name, handler in sorted(ENTRIES.items())
|
|
|
|
|
+ },
|
|
|
|
|
+ "actions": {
|
|
|
|
|
+ name: {"params": handler.params}
|
|
|
|
|
+ for name, handler in sorted(ACTIONS.items())
|
|
|
|
|
+ },
|
|
|
|
|
+ "assertions": sorted(ASSERTIONS),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def execute(args: argparse.Namespace) -> int:
|
|
def execute(args: argparse.Namespace) -> int:
|
|
|
try:
|
|
try:
|
|
|
scenario = load_scenario(args.scenario)
|
|
scenario = load_scenario(args.scenario)
|
|
@@ -577,6 +1106,8 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
raise ScenarioError("scenario readyMarker is not provided by the fixture")
|
|
raise ScenarioError("scenario readyMarker is not provided by the fixture")
|
|
|
if args.action_repeat < 1 or args.action_repeat > 100:
|
|
if args.action_repeat < 1 or args.action_repeat > 100:
|
|
|
raise ScenarioError("--action-repeat must be between 1 and 100")
|
|
raise ScenarioError("--action-repeat must be between 1 and 100")
|
|
|
|
|
+ if scenario["entry"]["type"] == "bookshelf" and args.action_repeat != 1:
|
|
|
|
|
+ raise ScenarioError("--action-repeat is only supported by reader scenarios")
|
|
|
except ScenarioError as error:
|
|
except ScenarioError as error:
|
|
|
print(json.dumps({"status": "failed", "exitCode": EXIT_SCENARIO, "error": str(error)}, ensure_ascii=False))
|
|
print(json.dumps({"status": "failed", "exitCode": EXIT_SCENARIO, "error": str(error)}, ensure_ascii=False))
|
|
|
return EXIT_SCENARIO
|
|
return EXIT_SCENARIO
|
|
@@ -591,67 +1122,79 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
return EXIT_ENVIRONMENT
|
|
return EXIT_ENVIRONMENT
|
|
|
|
|
|
|
|
session_id, run_dir, metadata = create_session(args.scenario, fixture, device)
|
|
session_id, run_dir, metadata = create_session(args.scenario, fixture, device)
|
|
|
- build: dict[str, Any] = {"status": "not_started"}
|
|
|
|
|
|
|
+ device_state: dict[str, Any] | None = None
|
|
|
try:
|
|
try:
|
|
|
- build, _ = build_and_install(device, run_dir)
|
|
|
|
|
- except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
|
|
|
|
|
- if isinstance(error, CommandError):
|
|
|
|
|
- build = {
|
|
|
|
|
- "status": "failed",
|
|
|
|
|
- "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
|
|
|
|
|
- }
|
|
|
|
|
- summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
|
|
|
|
|
- cleanup_device(device)
|
|
|
|
|
|
|
+ device_state = prepare_device(device)
|
|
|
|
|
+ metadata["deviceStateBefore"] = device_state
|
|
|
|
|
+ write_json(run_dir / "metadata.json", metadata)
|
|
|
|
|
+ except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
|
|
|
|
|
+ summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
|
|
|
write_json(run_dir / "summary.json", summary)
|
|
write_json(run_dir / "summary.json", summary)
|
|
|
print(run_dir / "summary.json")
|
|
print(run_dir / "summary.json")
|
|
|
- return EXIT_BUILD
|
|
|
|
|
|
|
+ return EXIT_ENVIRONMENT
|
|
|
|
|
|
|
|
- app_pid: str | None = None
|
|
|
|
|
|
|
+ build: dict[str, Any] = {"status": "not_started"}
|
|
|
|
|
+ if args.skip_build:
|
|
|
|
|
+ build = {"status": "skipped"}
|
|
|
|
|
+ else:
|
|
|
|
|
+ try:
|
|
|
|
|
+ build, _ = build_and_install(device, run_dir)
|
|
|
|
|
+ except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
|
|
|
|
|
+ if isinstance(error, CommandError):
|
|
|
|
|
+ build = {
|
|
|
|
|
+ "status": "failed",
|
|
|
|
|
+ "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
|
|
|
|
|
+ }
|
|
|
|
|
+ summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
|
|
|
|
|
+ cleanup_device(device)
|
|
|
|
|
+ restore_device(device, device_state)
|
|
|
|
|
+ write_json(run_dir / "summary.json", summary)
|
|
|
|
|
+ print(run_dir / "summary.json")
|
|
|
|
|
+ return EXIT_BUILD
|
|
|
|
|
+
|
|
|
|
|
+ entry_handler = ENTRIES[scenario["entry"]["type"]]
|
|
|
|
|
+ ctx = Context(device, run_dir, scenario, fixture_data, metadata, args.action_repeat)
|
|
|
|
|
+ recorder = ScreenRecorder(device) if scenario.get("record") else None
|
|
|
try:
|
|
try:
|
|
|
- launch_fixture(device, fixture, session_id)
|
|
|
|
|
- app_pid = application_pid(device)
|
|
|
|
|
- ready, before_dump, before_content = wait_until_reader_ready(
|
|
|
|
|
- device,
|
|
|
|
|
- scenario["readyMarker"],
|
|
|
|
|
- scenario.get("readyTimeoutSeconds", 30),
|
|
|
|
|
- )
|
|
|
|
|
- capture_screenshot(device, run_dir / "before.png")
|
|
|
|
|
- action_count = perform_actions(device, scenario, args.action_repeat) if ready else 0
|
|
|
|
|
- time.sleep(0.5)
|
|
|
|
|
- after_dump = ui_dump(device)
|
|
|
|
|
- after_content = extract_reader_content(after_dump)
|
|
|
|
|
- capture_screenshot(device, run_dir / "after.png")
|
|
|
|
|
- focus = current_focus(device)
|
|
|
|
|
- activity_visible = PACKAGE in focus and MAIN_ACTIVITY in focus
|
|
|
|
|
- content_changed = ready and bool(after_content) and before_content != after_content
|
|
|
|
|
- logcat = collect_logcat(device, run_dir, app_pid, session_id)
|
|
|
|
|
- failure = parse_failure(extract_crash(logcat, PACKAGE, app_pid))
|
|
|
|
|
|
|
+ launch_fixture(device, fixture, entry_handler.type, session_id, scenario.get("preferences"))
|
|
|
|
|
+ ctx.note_pid()
|
|
|
|
|
+ entry_handler.prepare(ctx)
|
|
|
|
|
+ if recorder is not None:
|
|
|
|
|
+ recorder.start()
|
|
|
|
|
+ for action in scenario["actions"]:
|
|
|
|
|
+ ACTIONS[action["type"]].run(ctx, action)
|
|
|
|
|
+ if recorder is not None:
|
|
|
|
|
+ time.sleep(0.5) # tail so the settle after the last action is captured
|
|
|
|
|
+ video = recorder.stop(run_dir)
|
|
|
|
|
+ if video:
|
|
|
|
|
+ ctx.artifacts["transition"] = video
|
|
|
|
|
+ entry_handler.finalize(ctx)
|
|
|
|
|
+ ctx.note_pid()
|
|
|
|
|
+ logcat = collect_logcat(device, run_dir, ctx.app_pids, session_id)
|
|
|
|
|
+ ctx.failure = parse_failure(extract_crash(logcat, PACKAGE, ctx.app_pids))
|
|
|
|
|
+ for name in scenario["assertions"]:
|
|
|
|
|
+ ctx.assertion_values[name] = ASSERTIONS[name](ctx)
|
|
|
metadata.update(
|
|
metadata.update(
|
|
|
{
|
|
{
|
|
|
"finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
"finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
|
- "actionRepeat": args.action_repeat,
|
|
|
|
|
- "uiBeforeSha256": sha256_text(before_dump),
|
|
|
|
|
- "uiAfterSha256": sha256_text(after_dump),
|
|
|
|
|
- "readerContentBeforeSha256": sha256_text(before_content),
|
|
|
|
|
- "readerContentAfterSha256": sha256_text(after_content),
|
|
|
|
|
- "appPid": app_pid,
|
|
|
|
|
|
|
+ "appPids": ctx.app_pids,
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
write_json(run_dir / "metadata.json", metadata)
|
|
write_json(run_dir / "metadata.json", metadata)
|
|
|
- summary, exit_code = make_summary(
|
|
|
|
|
|
|
+ summary, exit_code = make_summary_from_assertions(
|
|
|
session_id=session_id,
|
|
session_id=session_id,
|
|
|
scenario=scenario,
|
|
scenario=scenario,
|
|
|
fixture=fixture,
|
|
fixture=fixture,
|
|
|
build=build,
|
|
build=build,
|
|
|
- ready=ready,
|
|
|
|
|
- activity_visible=activity_visible,
|
|
|
|
|
- content_changed=content_changed,
|
|
|
|
|
- action_count=action_count,
|
|
|
|
|
- failure=failure,
|
|
|
|
|
|
|
+ assertion_values=ctx.assertion_values,
|
|
|
|
|
+ action_count=ctx.action_count,
|
|
|
|
|
+ failure=ctx.failure,
|
|
|
|
|
+ extra_artifacts=ctx.artifacts,
|
|
|
)
|
|
)
|
|
|
except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
|
|
except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
|
|
|
try:
|
|
try:
|
|
|
- collect_logcat(device, run_dir, app_pid, session_id)
|
|
|
|
|
|
|
+ ctx.note_pid()
|
|
|
|
|
+ collect_logcat(device, run_dir, ctx.app_pids, session_id)
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
summary = error_summary(
|
|
summary = error_summary(
|
|
@@ -665,7 +1208,10 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
)
|
|
)
|
|
|
exit_code = EXIT_ENVIRONMENT
|
|
exit_code = EXIT_ENVIRONMENT
|
|
|
finally:
|
|
finally:
|
|
|
|
|
+ if recorder is not None:
|
|
|
|
|
+ recorder.stop(run_dir)
|
|
|
cleanup_device(device)
|
|
cleanup_device(device)
|
|
|
|
|
+ restore_device(device, device_state)
|
|
|
write_json(run_dir / "summary.json", summary)
|
|
write_json(run_dir / "summary.json", summary)
|
|
|
print(run_dir / "summary.json")
|
|
print(run_dir / "summary.json")
|
|
|
return exit_code
|
|
return exit_code
|
|
@@ -679,6 +1225,12 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
|
|
|
run_parser.add_argument("--device", help="adb device serial; optional when exactly one is ready")
|
|
run_parser.add_argument("--device", help="adb device serial; optional when exactly one is ready")
|
|
|
run_parser.add_argument("--fixture", help="override the scenario fixture ID")
|
|
run_parser.add_argument("--fixture", help="override the scenario fixture ID")
|
|
|
run_parser.add_argument("--action-repeat", type=int, default=1, help="repeat each domain action (default: 1)")
|
|
run_parser.add_argument("--action-repeat", type=int, default=1, help="repeat each domain action (default: 1)")
|
|
|
|
|
+ run_parser.add_argument(
|
|
|
|
|
+ "--skip-build",
|
|
|
|
|
+ action="store_true",
|
|
|
|
|
+ help="reuse the installed debug APK; skip Gradle build and install",
|
|
|
|
|
+ )
|
|
|
|
|
+ subparsers.add_parser("list", help="print supported entries, actions, and assertions")
|
|
|
return parser.parse_args(argv)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -686,6 +1238,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
|
if args.command == "run":
|
|
if args.command == "run":
|
|
|
return execute(args)
|
|
return execute(args)
|
|
|
|
|
+ if args.command == "list":
|
|
|
|
|
+ print(json.dumps(build_capabilities(), ensure_ascii=False, indent=2))
|
|
|
|
|
+ return 0
|
|
|
return EXIT_SCENARIO
|
|
return EXIT_SCENARIO
|
|
|
|
|
|
|
|
|
|
|