Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# AGENTS.md

Guidance for AI coding agents working on `iOS-Automation-Framework`.

This repository is the companion test-code project for MeteorTest. It owns pytest API suites, Appium/XCUITest UI suites, Allure output, and the `meteortest.yml` integration contract used by the platform.

## Working Rules

- Keep changes scoped to the requested test, framework, documentation, or integration behavior.
- Do not commit private credentials, internal URLs, local device identifiers, tokens, or test accounts.
- Do not push directly to `main`; use a `dev/v-peq/<topic>` branch and open a PR against the mother repository.
- Prefer deterministic local validation before claiming behavior in public docs.

## Synchronization Rule

When backend behavior, test behavior, interaction flow, environment variables, ports, commands, integration contracts, mock services, or capability/status claims change, update all affected surfaces in the same change:

- implementation code
- tests
- `meteortest.yml` when platform commands or suite behavior change
- English README
- Chinese README
- related project documentation

Do not treat a code-only or documentation-only update as complete if another surface still describes or implements the old behavior.

## Terminology

- English technical docs may use `contract` for `meteortest.yml` and integration boundaries.
- Chinese public docs should prefer `协议` instead of `契约`.

## Validation

Use the project virtual environment when available:

```powershell
.venv\Scripts\python.exe -m pytest tests -q -n 0
.venv\Scripts\python.exe -m pytest API_Automation\cases -v -n 0 -m smoke
.venv\Scripts\python.exe -m compileall API_Automation UI_Automation tools tests
```

For API smoke tests, set `API_BASE_URL` explicitly. The local mock API default is:

```powershell
.venv\Scripts\python.exe -m tools.mock_api.server --host 127.0.0.1 --port 8010
$env:API_BASE_URL="http://127.0.0.1:8010"
```
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ $env:API_BASE_URL="https://your-staging-api.example.com"

For MeteorTest Local Agent runs, set `API_BASE_URL` in the same shell before starting the Agent so the suite subprocess inherits it.

### Local mock API for smoke evidence

For public-safe local validation, this repository includes a small mock API that covers the current `-m smoke` API cases. It lets the smoke suite produce real pass/fail results without depending on a private staging backend.

Start the mock API:

```powershell
.venv\Scripts\python.exe -m tools.mock_api.server --host 127.0.0.1 --port 8010
```

In another shell, run the smoke suite against it:

```powershell
$env:API_BASE_URL="http://127.0.0.1:8010"
.venv\Scripts\python.exe -m pytest API_Automation\cases -v -n 0 -m smoke
```

Boundary: the mock API is deterministic local test infrastructure. It is not the real product backend and should not be used to claim production API coverage.

## Local Demo Console

The repository includes a local Web UI for debugging and demonstration. It is useful for browsing code, running whitelisted tests, viewing real-time logs, opening Allure reports, and trying project-aware AI Q&A.
Expand Down
19 changes: 19 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ $env:API_BASE_URL="https://your-staging-api.example.com"

如果通过 MeteorTest Local Agent 运行,需要在启动 Agent 的同一个 shell 中设置 `API_BASE_URL`,这样 suite 子进程才能继承这个变量。

### 用于 smoke 证据的本地 Mock API

为了做公开安全的本地验证,本仓库内置了一个小型 mock API,覆盖当前 `-m smoke` API 用例需要的接口。它可以让 smoke suite 产生真实的 pass/fail 结果,而不依赖私有 staging 后端。

启动 mock API:

```powershell
.venv\Scripts\python.exe -m tools.mock_api.server --host 127.0.0.1 --port 8010
```

另开一个 shell,把 smoke suite 指向它:

```powershell
$env:API_BASE_URL="http://127.0.0.1:8010"
.venv\Scripts\python.exe -m pytest API_Automation\cases -v -n 0 -m smoke
```

边界:mock API 是确定性的本地测试基础设施。它不是被测产品的真实后端,不能用来宣称已经覆盖生产 API。

## 本地 Demo 控制台

仓库内置一个本地 Web UI,用于调试和演示。它可以浏览代码、执行白名单内测试、查看实时日志、打开 Allure 报告,并尝试项目上下文 AI 问答。
Expand Down
65 changes: 65 additions & 0 deletions tests/test_mock_api_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

import threading

import requests

from tools.mock_api.server import create_server


def test_mock_api_supports_smoke_endpoints():
server = create_server(port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()

base_url = f"http://127.0.0.1:{server.server_address[1]}"
try:
login = requests.post(
f"{base_url}/user/login",
json={"phone": "13800138000", "code": "123456"},
timeout=5,
).json()
assert login["code"] == 0
assert login["data"]["token"]

user = requests.get(
f"{base_url}/user/info",
headers={"Authorization": f"Bearer {login['data']['token']}"},
timeout=5,
).json()
assert user["code"] == 0
assert user["data"]["user_id"]

products = requests.get(
f"{base_url}/product/search",
params={"keyword": "手机"},
timeout=5,
).json()
assert products["code"] == 0
assert len(products["data"]["products"]) > 0

categories = requests.get(
f"{base_url}/product/category/list",
params={"parent_id": 0},
timeout=5,
).json()
assert categories["code"] == 0
assert len(categories["data"]) >= 5

orders = requests.get(f"{base_url}/order/list", timeout=5).json()
assert orders["code"] == 0
assert "orders" in orders["data"]

added = requests.post(
f"{base_url}/cart/add",
json={"product_id": 10001, "spec_id": 20001, "quantity": 1},
timeout=5,
).json()
assert added["code"] == 0

cart = requests.get(f"{base_url}/cart/list", timeout=5).json()
assert cart["code"] == 0
assert len(cart["data"]["items"]) > 0
finally:
server.shutdown()
server.server_close()
1 change: 1 addition & 0 deletions tools/mock_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Local mock API package for API smoke tests."""
220 changes: 220 additions & 0 deletions tools/mock_api/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Local mock API for deterministic smoke-test execution.

The server intentionally covers only the endpoints exercised by
API_Automation/cases with the smoke marker. It is not a replacement for a
staging backend.
"""
from __future__ import annotations

import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse


TOKEN = "mock-token-13800138000"

PRODUCTS = [
{
"id": 10001,
"name": "Mock iPhone 15",
"price": 5999,
"images": ["https://example.test/products/10001.png"],
"specs": [{"id": 20001, "name": "Default", "stock": 99}],
},
{
"id": 10002,
"name": "Mock Android Phone",
"price": 2999,
"images": ["https://example.test/products/10002.png"],
"specs": [{"id": 20002, "name": "Default", "stock": 99}],
},
]

CATEGORIES = [
{"id": 1, "name": "Digital", "parent_id": 0},
{"id": 2, "name": "Home", "parent_id": 0},
{"id": 3, "name": "Sports", "parent_id": 0},
{"id": 4, "name": "Books", "parent_id": 0},
{"id": 5, "name": "Beauty", "parent_id": 0},
{"id": 11, "name": "Phones", "parent_id": 1},
{"id": 12, "name": "Headphones", "parent_id": 1},
{"id": 13, "name": "Accessories", "parent_id": 1},
]

DEFAULT_CART_ITEM = {
"cart_item_id": 50001,
"product_id": 10001,
"spec_id": 20001,
"quantity": 1,
"selected": True,
"price": 5999,
}


def success(data: Any = None, msg: str = "ok") -> dict[str, Any]:
return {"code": 0, "msg": msg, "data": {} if data is None else data}


def error(code: int, msg: str, data: Any = None) -> dict[str, Any]:
return {"code": code, "msg": msg, "data": {} if data is None else data}


class MockAPIHandler(BaseHTTPRequestHandler):
server_version = "iOSAutomationMockAPI/1.0"

def do_GET(self) -> None: # noqa: N802 - stdlib handler method
parsed = urlparse(self.path)
query = parse_qs(parsed.query)

if parsed.path == "/health":
self._write_json(success({"status": "ok"}))
return

if parsed.path == "/user/info":
self._write_json(
success(
{
"user_id": 100001,
"id": 100001,
"phone": "13800138000",
"nickname": "Mock User",
}
)
)
return

if parsed.path == "/product/search":
keyword = query.get("keyword", [""])[0]
products = [] if "not_exist" in keyword else PRODUCTS
self._write_json(success({"products": products, "total": len(products)}))
return

if parsed.path == "/product/category/list":
parent_id = int(query.get("parent_id", ["0"])[0])
categories = [item for item in CATEGORIES if item["parent_id"] == parent_id]
self._write_json(success(categories))
return

if parsed.path == "/order/list":
status = query.get("status", ["all"])[0]
orders = [
{
"order_no": "MOCK_ORDER_001",
"status": "pending",
"total_amount": 5999,
"items": [DEFAULT_CART_ITEM],
}
]
if status != "all":
orders = [order for order in orders if order["status"] == status]
self._write_json(success({"orders": orders, "total": len(orders)}))
return

if parsed.path == "/cart/list":
self._write_json(success({"items": [DEFAULT_CART_ITEM]}))
return

if parsed.path == "/cart/summary":
self._write_json(success({"item_count": 1, "total_price": 5999}))
return

if parsed.path == "/cart/count":
self._write_json(success({"count": 1}))
return

self._write_json(error(404, f"Unhandled path: {parsed.path}"), status=404)

def do_POST(self) -> None: # noqa: N802 - stdlib handler method
parsed = urlparse(self.path)
body = self._read_json()

if parsed.path in {"/user/login", "/user/login/password"}:
phone = body.get("phone", "13800138000")
self._write_json(success({"token": TOKEN, "phone": phone}))
return

if parsed.path == "/cart/add":
item = {
**DEFAULT_CART_ITEM,
"product_id": body.get("product_id", 10001),
"spec_id": body.get("spec_id", 20001),
"quantity": body.get("quantity", 1),
}
self._write_json(success({"item": item}))
return

if parsed.path in {"/cart/clear", "/cart/select", "/cart/select/all"}:
self._write_json(success())
return

self._write_json(error(404, f"Unhandled path: {parsed.path}"), status=404)

def do_PUT(self) -> None: # noqa: N802 - stdlib handler method
parsed = urlparse(self.path)

if parsed.path.startswith("/cart/item/"):
self._write_json(success({"item": DEFAULT_CART_ITEM}))
return

if parsed.path == "/user/profile":
body = self._read_json()
self._write_json(success({"nickname": body.get("nickname", "Mock User")}))
return

self._write_json(error(404, f"Unhandled path: {parsed.path}"), status=404)

def do_DELETE(self) -> None: # noqa: N802 - stdlib handler method
parsed = urlparse(self.path)

if parsed.path.startswith("/cart/item/") or parsed.path.startswith("/user/address/"):
self._write_json(success())
return

self._write_json(error(404, f"Unhandled path: {parsed.path}"), status=404)

def log_message(self, format: str, *args: Any) -> None:
return

def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length == 0:
return {}
raw = self.rfile.read(length)
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
return {}

def _write_json(self, payload: dict[str, Any], status: int = 200) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)


def create_server(host: str = "127.0.0.1", port: int = 8010) -> ThreadingHTTPServer:
return ThreadingHTTPServer((host, port), MockAPIHandler)


def main() -> None:
parser = argparse.ArgumentParser(description="Run the local API smoke mock server.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8010)
args = parser.parse_args()

server = create_server(args.host, args.port)
print(f"Mock API listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()


if __name__ == "__main__":
main()
Loading