From 2274b0b4de722fa3b7cb00eeeb1c989b37685f36 Mon Sep 17 00:00:00 2001 From: ConnorQi01 Date: Fri, 8 May 2026 11:42:54 +0800 Subject: [PATCH 1/2] Add local mock API for smoke tests --- README.md | 19 +++ README.zh-CN.md | 19 +++ tests/test_mock_api_contract.py | 65 ++++++++++ tools/mock_api/__init__.py | 1 + tools/mock_api/server.py | 220 ++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 tests/test_mock_api_contract.py create mode 100644 tools/mock_api/__init__.py create mode 100644 tools/mock_api/server.py diff --git a/README.md b/README.md index 8733c1e..9b0219f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 46d9c9d..d917ac7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 问答。 diff --git a/tests/test_mock_api_contract.py b/tests/test_mock_api_contract.py new file mode 100644 index 0000000..40ade00 --- /dev/null +++ b/tests/test_mock_api_contract.py @@ -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() diff --git a/tools/mock_api/__init__.py b/tools/mock_api/__init__.py new file mode 100644 index 0000000..ef1df17 --- /dev/null +++ b/tools/mock_api/__init__.py @@ -0,0 +1 @@ +"""Local mock API package for API smoke tests.""" diff --git a/tools/mock_api/server.py b/tools/mock_api/server.py new file mode 100644 index 0000000..0075380 --- /dev/null +++ b/tools/mock_api/server.py @@ -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() From 3afaea77caddf610525d367d3692ed47a14330c6 Mon Sep 17 00:00:00 2001 From: ConnorQi01 Date: Fri, 8 May 2026 11:44:01 +0800 Subject: [PATCH 2/2] Add agent synchronization rules --- AGENTS.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1d54960 --- /dev/null +++ b/AGENTS.md @@ -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/` 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" +```