Metadata-Version: 2.4
Name: kyapp
Version: 0.0.27
Summary: 安卓/IOS自动化测试框架
Home-page: https://gitee.com/bluepang2021/kyapp_project
Author: 杨康
Author-email: 772840356@qq.com
Platform: Android/IOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10,<3.13
Description-Content-Type: text/markdown
Requires-Dist: click==8.1.7
Requires-Dist: loguru==0.7.0
Requires-Dist: PyYAML==6.0.1
Requires-Dist: allure-pytest==2.9.45
Requires-Dist: pytest==6.2.5
Requires-Dist: uiviewer==1.1.3
Requires-Dist: uiautomator2==3.2.4
Requires-Dist: tidevice==0.12.11
Requires-Dist: facebook-wda==1.5.0
Requires-Dist: numpy==1.26.4
Requires-Dist: opencv-python==4.6.0.66
Requires-Dist: chardet==5.2.0
Requires-Dist: natocr==2.3.0
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: platform
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# kyapp 使用手册

面向"要写安卓 / iOS UI 自动化用例"的人。看完这一篇能照着自己项目搭起来，跑通第一个用例。

参考实现：仓库里的 `demo/`（一个完整的可运行工程，建议先跑通它再改成自己的）。

---

## 1. 它能给你什么

- **一套 API 同时覆盖 iOS 和安卓**：同一份页面对象、同一份用例，靠 `platform` 参数切换。
- **页面对象（PO）模式**：`Elem` 声明式定位，`Page` 承载页面。
- **自动处理设备服务**：iOS 自动起 tunnel / WDA / 端口转发；安卓自动连 uiautomator2。
- **失败现场**：元素找不到自动截图并挂到 allure。
- **录屏、报告**：一条 `start_record` / `stop_record`，报告用 allure 生成。
- **弹窗兜底**：配置一组"总是要关掉的弹窗"，查找元素失败时会自动尝试点掉。

它**不提供**断言库、不提供数据驱动 DSL、不提供用例管理平台——断言直接用 `assert`，数据用 pytest 原生能力。

---

## 2. 环境准备

### Python

`kyapp` 要求 **Python 3.10 ~ 3.12**（3.13 装不上，因为 numpy 1.26 没有 cp313 wheel）。

```bash
python -m venv .venv
source .venv/bin/activate
pip install kyapp          # 内网用私有源: pip install kyapp -i <你的源>
```

### iOS 侧（只有跑 iOS 才需要）

| 依赖 | 说明 | 检查命令 |
|---|---|---|
| macOS + Xcode | WDA 依赖 Xcode 的工具链 | `xcodebuild -version` |
| go-ios | 起隧道、跑 WDA，可执行文件叫 `ios` | `ios version` |
| tidevice | WDA / MJPEG 的端口转发 | `tidevice --version` |
| ffmpeg | 录屏编码 | `ffmpeg -version` |
| WebDriverAgent | 需要**自己签名并装到设备上** | 见下方注意 |

> **注意（重要）**：框架里 WDA 的 bundle id 是**写死**的
> `com.lute.WebDriverAgentRunner.xctrunner`（`kyapp/core/ios/local_wda.py` 的 `_wda_cmd`）。
> 你要么按这个 id 签名安装 WDA，要么改那一处代码改成自己的。签名不对时表现为
> `wda启动失败`，详细原因看 `~/.kyapp/logs/<udid>/runwda.log`。

### 安卓侧

| 依赖 | 说明 | 检查命令 |
|---|---|---|
| adb | 设备连接 | `adb devices` |
| uiautomator2 初始化 | 首次需给设备装 ATX agent | `python -m uiautomator2 init` |
| scrcpy | 录屏用（不录屏可不装） | `scrcpy --version` |

### 报告

```bash
brew install allure     # 或参考 allure 官方安装方式
allure --version
```

---

## 3. 五分钟跑通

### 3.1 目录结构

```
your_project/
├── run_ios.py          # iOS 入口（可多个，一个平台一个）
├── run_adr.py          # 安卓入口
├── config.py           # 自己的测试数据（账号密码等）
├── pages/              # 页面对象
│   ├── __init__.py
│   ├── home_page.py
│   └── login_page.py
└── tests/              # 用例
    ├── __init__.py
    └── test_go_me.py
```

### 3.2 页面对象

`pages/home_page.py`：

```python
from kyapp import Elem, Page


class HomePage(Page):
    me_tab = Elem(
        title='底部我的tab入口',
        adr=('resourceId', 'com.lute.momcozy:id/i_me'),
        ios=('label', 'Me'),
    )
```

- `title`：元素的**中文描述**，出现在日志和失败截图文件名里，写清楚能省很多排查时间。
- `adr` / `ios`：`(定位方式, 定位值)`。**两个都必须写**（见 7.1）。

### 3.3 用例

`tests/test_go_me.py`：

```python
import kyapp
from pages.home_page import HomePage


class TestGoMe(kyapp.TestCase):

    def start(self):
        """每个用例开始都会执行：启动 App、准备页面对象"""
        self.start_app()
        self.home_page = HomePage(self.dr)

    def test_go_me(self):
        self.home_page.me_tab.click()
        assert self.home_page.me_tab.exists(), '进入"我的"页面失败'
```

要点：
- 类名必须以 `Test` 开头、继承 `kyapp.TestCase`；方法名以 `test_` 开头（pytest 规则）。
- `self.dr` 由框架创建，在 `start()` 里就能用。
- 断言就是普通 `assert`。

### 3.4 入口

`run_ios.py`：

```python
import kyapp

if __name__ == '__main__':
    kyapp.main(
        case_path='tests',
        did='00008150-0018581E0A8A401C',   # iOS 传 udid
        pkg='com.root.momcozy',            # iOS 传 bundle id
        platform='ios',
    )
```

`run_adr.py`：

```python
import kyapp

if __name__ == '__main__':
    kyapp.main(
        case_path='tests',
        did='R5CY40W0Q2J',                 # 安卓传 serial（adb devices 第二列）
        pkg='com.lute.momcozy',            # 安卓传 package name
        platform='android',
        pop_list=[
            ('多设备连接弹窗', 'resourceId', 'com.lute.momcozy:id/ivClose'),
        ],
    )
```

运行（**必须在本项目目录下**，`case_path` 是相对路径）：

```bash
python run_ios.py
allure serve report        # 看报告
```

### 3.5 `kyapp.main` 参数

| 参数 | 必填 | 说明 |
|---|---|---|
| `case_path` | ✅ | 用例目录，如 `'tests'`；相对**当前工作目录** |
| `did` | ✅ | iOS 传 udid，安卓传 serial |
| `pkg` | ✅ | iOS 传 bundle id，安卓传 package name |
| `platform` | ✅ | 只能是 `'ios'` 或 `'android'`（写错会在入口直接报错） |
| `report_path` | | allure 结果目录，默认 `'report'` |
| `pop_list` | | 弹窗兜底列表，见第 6 节 |

调用后框架会执行 `pytest.main(['tests', '-sv', '--alluredir', 'report', '--clean-alluredir'])`。

---

## 4. 用例生命周期

每个测试**方法**都会走一遍：

```
setup_class()  → start_class()        # 类级，一次
setup_method() → 建 driver → start()
   test_xxx()
teardown_method() → end() → 关 driver
teardown_class() → end_class()        # 类级，一次
```

四条 hook 都是**空实现**，按需覆盖：

| hook | 时机 | 典型用途 |
|---|---|---|
| `start_class` / `end_class` | 整个类前后各一次 | 造数据、登录一次 |
| `start` | 每个用例前 | `start_app()`、实例化页面对象 |
| `end` | 每个用例后 | 清理、回到首页 |

**关键行为：每个 `test_` 方法都会重建一次 driver。** iOS 上还会先清掉旧的 tunnel / runwda / relay 进程再重建，所以**单个用例有 5~15 秒固定开销**。因此：

- 一个 `test_` 方法里应该走完一个**完整场景**，不要拆成一堆只点一下的微用例；
- `start_class` 里做的准备**不会**跨用例保留 driver（driver 每个方法都换）。

> `setup_method` 里如果 `start()` 抛异常，框架只打 `logger.error` 不失败用例。
> 所以**关键前置条件**（比如"必须登录成功"）要写成 `test_` 里的 `assert`，别只写在 `start()`。

`TestCase` 自带的便捷方法：

| 方法 | 说明 |
|---|---|
| `self.start_app(force=True, activity=None, use_monkey=False)` | 启动 App，`force=True` 会先停掉 |
| `self.stop_app()` | 停止 App |
| `self.back()` | 返回上一页（iOS 用右滑手势，安卓用 back 键） |
| `self.sleep(n)` | 带日志的 `time.sleep` |
| `self.shot(name, delay=0)` | 截图并自动挂到 allure |
| `self.start_record(name)` / `self.stop_record()` | 录屏，见第 8 节 |

---

## 5. 元素定位

### 5.1 统一写法

```python
Elem(
    title='登录按钮',
    adr=('resourceId', 'com.lute.momcozy:id/btnLogin'),
    ios=('label', 'Log In'),
)
```

同一个元素两套定位符，框架按 `platform` 自动选一个。

### 5.2 常用定位方式

| `loc_key` | 安卓（uiautomator2） | iOS（facebook-wda） |
|---|---|---|
| `text` | 文案精确匹配 | 文案匹配（label/name/value） |
| `label` | — | 无障碍 label |
| `name` | — | 控件 name |
| `resourceId` | 资源 id，如 `com.x:id/btn` | — |
| `className` | 类名，如 `android.widget.Button` | 类名，如 `TextField`、`SecureTextField`、`Button` |
| `textContains` | 文案包含 | — |
| `description` | content-desc | — |
| `xpath` | `//*[@text="登录"]` | `//Button[@name="登录"]` |

除 `xpath` 外，其余都直接透传给底层库的选择器（uiautomator2 / facebook-wda），所以两个库支持的参数基本都能用。

### 5.3 元素方法

| 方法 | 说明 |
|---|---|
| `find(timeout=5, retry=True, error_screen=True)` | 找元素，返回底层元素对象；失败时截图并抛异常 |
| `exists(timeout=5, retry=False)` | 是否存在，返回 `bool`，**不抛异常、不截图** |
| `click(timeout=5)` | 点击 |
| `click_exists(timeout=5)` | 存在才点，不存在静默跳过 |
| `input(text, timeout=5)` | 输入（iOS 用 `set_text`，安卓点一下再输入） |
| `clear()` | 清空输入框 |
| `get_text(timeout=5)` | 取文本 |

**`retry` 的含义**：`find(retry=True)`（默认）在第一次找不到时会重试 3 轮，每轮先尝试点掉弹窗（第 6 节）再找。这个路径比较慢，**只想判断"在不在"时用 `exists()`**。

### 5.4 页面对象

`Page` 只是个持有 driver 的容器：

```python
from kyapp import Elem, Page


class MyPage(Page):
    my_profile = Elem(
        title='My Profile入口',
        ios=('label', 'My Profile'),
        adr=('text', 'My Profile'),
    )
```

页面方法里可以直接 `self.driver`，也可以把"登录"这类业务动作封在页面对象或单独的 Api 类里（`demo/pages/login_page.py` 的 `LoginApi` 就是这种写法）。

---

## 6. 弹窗处理

系统权限弹窗、升级弹窗、地区弹窗会挡住元素。传 `pop_list` 给入口：

```python
kyapp.main(
    ...,
    pop_list=[
        ('多设备连接弹窗', 'resourceId', 'com.lute.momcozy:id/ivClose'),
        ('升级提示',       'text',       '稍后再说'),
    ],
)
```

每个元素是 `(描述, 定位方式, 定位值)`。触发时机：`Elem.find()` 的重试过程中。规则：

- 检测是**并发只读查询**，但**点击统一回到主线程**，且每轮只点第一个命中的——
  顺序就是你写 `pop_list` 的顺序，可以当成优先级用。同时命中多个会打一条 WARNING 把其余几个列出来。
- 点完会**确认元素真的消失了**：没消失说明它大概率是页面上的常驻元素（不是弹窗），
  此时直接停止检测，不再继续点后面的。
- 有硬上限：最多点 `timeout` 次、最多 `2 × timeout` 轮（默认 3 次 / 6 轮），
  所以不会出现"命中→重置→再命中"的死循环。
- `pop_list` 只支持控件树定位；`ocr` / `image` 这类截图定位会直接报错，不会静默失败。

不传（`None`）则完全跳过弹窗处理。**不要把"取消 / 删除 / 提交"这类有业务含义的文案放进 `pop_list`**——
它是全页面匹配，一旦误命中就会点到业务按钮上。

---

## 7. 容易踩的坑

### 7.1 `adr` 和 `ios` 必须都写

`Elem` 构造时会把当前平台对应的元组解包，另一边是 `None` 会直接：

```
TypeError: cannot unpack non-iterable NoneType object
```

即使你只跑一个平台，也要把另一边的定位符随便写上（比如复制一份）。

### 7.2 页面模块不能在 `App.platform` 设置之前被 import

`Elem` 在**导入时就决定**用哪套定位符：

```python
if App.platform == 'ios':
    self.loc_key, self.loc_value = ios
else:
    self.loc_key, self.loc_value = adr
```

所以不要在 `run_ios.py` 顶部 `from pages.home_page import HomePage`——那时 `App.platform` 还是 `None`，会**静默**走成安卓分支。正确做法是由 `tests/` 里的用例模块去 import 页面（pytest 收集发生在 `kyapp.main()` 内部，此时参数已经写好）。

### 7.3 `case_path` 相对当前目录

`python run_ios.py` 要在项目根目录执行。在别的目录跑会报 `file or directory not found: tests`。

### 7.4 `order()` / `depend()` 需要额外装插件

`kyapp.order` 和 `kyapp.depend` 只是生成 pytest 标记：

```python
import kyapp

class TestX(kyapp.TestCase):
    @kyapp.order(1)
    def test_a(self): ...

    @kyapp.depend('test_a')
    def test_b(self): ...
```

这两个标记分别要 `pytest-ordering` 和 `pytest-dependency` 才生效。**没装时 pytest 只打 warning、标记静默失效**，用例顺序/依赖不会按你写的来。需要就用：

```bash
pip install pytest-ordering==0.6 pytest-dependency==0.5.1
```

（这两个目前不在 kyapp 的依赖里，属于可选。）

### 7.5 iOS 出问题先看日志

每台设备的子进程日志在 `~/.kyapp/logs/<udid>/`：

| 文件 | 看什么 |
|---|---|
| `tunnel.log` | 隧道有没有建起来、端口有没有冲突 |
| `runwda.log` | **最常见的失败原因**：WDA 没装、签名不对、设备没信任 |
| `relay.log` | 端口转发（8100）起不来 |

排查顺序：先 `runwda.log`，再 `tunnel.log`。

### 7.6 别依赖 `kytest` 命令

wheel 里声明的 console script 是 `kytest = kytest.cli:main`，但本发行包内没有 `kytest` 包，装了也跑不起来。**用 `python run_xxx.py` 调 `kyapp.main`**。

### 7.7 磁盘产物

截图在运行目录的 `shots/`，录像在 `videos/`，文件名带时间戳 + 设备标识（并发跑多设备不会覆盖）。这两个目录会越来越大，记得定期清。

---

## 8. 截图与录屏

```python
def test_demo(self):
    self.shot('首页')          # 截图 + 自动 attach 到 allure
    self.start_record('下单流程')
    try:
        ...
    finally:
        self.stop_record()     # 结束录屏 + attach 到 allure
```

- `shot()` 一直可用。
- `start_record()` 返回录像路径，**录制没起来返回 `None`**；真正的产物以 `stop_record()` 的返回值为准。
- 录屏失败**不会让用例变红**，只打 ERROR 日志——它是辅助能力。缺 `ffmpeg` / `scrcpy` 这类环境问题才会抛 `FileNotFoundError`。
- 安卓录屏会给每台设备**固定分配一个本地端口**（27183–27199 里的一个，记在 `~/.kyapp/scrcpy_ports.json`）。
  这个范围只有 17 个，正常退出时会释放；如果看到"端口池已用尽"，说明有残留的 scrcpy 进程，
  按提示执行 `pgrep -fl scrcpy` 清掉即可。日志里的 `(端口 127.0.0.1:2718x)` 就是本次用的端口。

---

## 9. OCR 与图像定位

元素定位表里 `loc_key` 写 `ocr` 或 `image` 时，走截图识别而不是控件树：

```python
class HomePage(Page):
    banner = Elem(
        title='首页banner',
        adr=('ocr', '限时活动'),
        ios=('ocr', '限时活动'),
    )
    close_btn = Elem(
        title='关闭按钮',
        adr=('image', 'images/close.png'),
        ios=('image', 'images/close.png'),
    )
```

- `('ocr', '关键词')`：截图做 OCR 后按文本找，点击文字中心。
- `('image', '图片路径')`：模板匹配。
- **只支持 `click()` / `click_exists()`**，`find()` / `exists()` / `get_text()` 这类走控件树的方法对它们无效。
- OCR 默认中文（`zh-Hans`）；中英混排时英文关键词若第一次没命中，会自动用 `en-US` 再识别一次。可用环境变量 `KYAPP_OCR_LANG` 覆盖。

适合控件树拿不到的东西：Canvas 绘制、WebView 里的文字、游戏界面。

---

## 10. 多设备并行

`App` 是**进程级**全局，所以多设备就是多进程，每个进程各自调一次 `kyapp.main`：

```python
import multiprocessing
import kyapp


def run_case(did):
    kyapp.main(case_path='tests', did=did, pkg='com.root.momcozy', platform='ios')


if __name__ == '__main__':
    for did in ['00008150-0018581E0A8A401C', '00008110-001579541AF8401E']:
        multiprocessing.Process(target=run_case, args=(did,)).start()
```

注意几点：

- **每个子进程都要自己传参**，不要靠继承父进程的 `App`（macOS 默认 spawn，父进程的值根本不会带过来；Linux 上 fork 会带一份旧快照，更容易出错）。
- 每个进程都有独立的 `report` 目录才会各自出报告，否则会互相覆盖——多设备时给每个进程传不同的 `report_path`。
- 同一台机器跑两台 iOS 设备时，隧道端口由框架按设备自动分配，不需要手动错开。

---

## 11. 报告

```bash
allure serve report                  # 直接起服务看
allure generate report -o report/html --clean   # 生成静态报告
```

报告里包含：用例步骤、日志、失败截图、录屏附件。想在报告里加标注，直接用 allure 的装饰器（`kyapp` 已重新导出）：

```python
import kyapp

class TestX(kyapp.TestCase):
    @kyapp.feature('登录')
    @kyapp.story('账号密码登录')
    @kyapp.title('登录成功后进入首页')
    def test_login(self):
        with kyapp.step('输入账号密码'):
            ...
```

---

## 12. 环境变量

| 变量 | 默认 | 作用 |
|---|---|---|
| `KYAPP_WDA_TIMEOUT` | `60` | facebook-wda 单次 HTTP 请求超时（秒）。链路断了会卡这么久才报错 |
| `KYAPP_OCR_LANG` | `zh-Hans` | OCR 主语言，只接受单个语言码 |
| `KYAPP_OCR_FALLBACK_LANG` | `en-US` | 英文关键词兜底语言，置空关闭 |
| `KYAPP_TUNNEL_PORT_BASE` | `21800` | iOS 隧道端口池起点（每设备占一个 100 宽度的窗口） |

---

## 13. API 速查

```python
import kyapp

kyapp.main(case_path='tests', did='<udid/serial>', pkg='<bundle/package>',
           platform='ios', report_path='report', pop_list=None)
kyapp.TestCase      # 用例基类
kyapp.Page          # 页面基类
kyapp.Elem          # 元素描述符
kyapp.App           # 当前运行的会话参数（did / pkg / platform / pop_list）
kyapp.logger        # loguru logger
kyapp.data          # pytest.mark.parametrize 的简写
kyapp.order         # 用例顺序（需 pytest-ordering）
kyapp.depend        # 用例依赖（需 pytest-dependency）
kyapp.feature / story / title / step   # allure 标注
```

用例里读会话参数：

```python
from kyapp import App

print(App.did, App.pkg, App.platform)
```

页面/工具类里读会话参数请用 `getattr(App, 'x', 默认值)` 这种软依赖写法，这样脱离 `kyapp.main` 也能单独用。
