Files
RuoYi-Vue3-FastAPI/.github/scripts/mobile_smoke.py
T
insistence 025b0822df refactor: 统一多前端工程架构、插件配置与 CI (#131)
* refactor: 重构项目架构

* refactor: 重构ci和测试

* fix: 修复测试

* feat: 新增更多E2E测试

* fix: 修复E2E测试
2026-10-10 21:15:22 +08:00

185 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import asyncio
import json
import os
import re
import threading
import time
from collections.abc import Awaitable, Callable
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from playwright.async_api import Route, async_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
TOKEN = 'isolated-mobile-smoke-token'
USER = {
'userId': 7,
'userName': 'ci-user',
'nickName': '移动端 CI',
'avatar': '',
'phonenumber': '13800000000',
'email': 'mobile-ci@example.test',
'createTime': '2026-01-01T00:00:00Z',
'timeZone': 'America/New_York',
}
def create_api_handler(errors: list[str], observed: list[dict[str, Any]]) -> Callable[[Route], Awaitable[None]]:
"""
创建隔离 API 处理函数,向共享记录追加请求及异常。
:param errors: 本次验收收集的异常说明
:param observed: 实际发出的 API 请求记录
:return: 供浏览器路由使用的异步处理函数
"""
async def api(route: Route) -> None:
"""
隔离 API 边界,记录真实构建发出的认证和时区请求。
:param route: 当前浏览器 API 请求
:return: None
"""
request = route.request
path = urlparse(request.url).path
headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*'}
if request.method == 'OPTIONS':
await route.fulfill(status=204, headers=headers)
return
observed.append(
{
'path': path,
'method': request.method,
'authorization': request.headers.get('authorization'),
'timezone': request.headers.get('x-timezone'),
}
)
payload: dict[str, Any]
if path == '/transport/crypto/frontend-config':
payload = {
'code': 200,
'data': {
'transportCryptoEnabled': False,
'transportCryptoMode': 'off',
'transportCryptoActive': False,
'configExpireAt': int(time.time()) + 600,
},
}
elif path == '/captchaImage':
payload = {'code': 200, 'captchaEnabled': False}
elif path == '/login':
if 'ci-user' not in (request.post_data or ''):
errors.append('登录请求未包含输入的用户名')
payload = {'code': 200, 'token': TOKEN}
elif path == '/getInfo':
payload = {
'code': 200,
'user': USER,
'roles': ['admin'],
'permissions': ['*:*:*'],
'appTimezone': 'Asia/Shanghai',
}
elif path == '/system/user/profile':
payload = {'code': 200, 'data': USER, 'postGroup': 'CI', 'roleGroup': '管理员'}
elif path == '/logout':
payload = {'code': 200}
else:
errors.append(f'未预期的 API 请求:{path}')
await route.fulfill(status=500, json={'code': 500, 'msg': 'unexpected smoke request'}, headers=headers)
return
await route.fulfill(json=payload, headers=headers)
return api
async def run_smoke(framework: str, base_url: str, output: Path) -> None:
"""
使用真实 H5 构建验证登录、持久化身份和个人资料页。
:param framework: vue2 或 vue3
:param base_url: 仅用于本次测试的静态文件服务器
:param output: 浏览器报告目录
:return: None
"""
logs: list[str] = []
errors: list[str] = []
observed: list[dict[str, Any]] = []
api = create_api_handler(errors, observed)
async with async_playwright() as playwright:
executable = os.environ.get('RUOYI_SMOKE_BROWSER_EXECUTABLE')
browser = await playwright.chromium.launch(
headless=True, **({'executable_path': executable} if executable else {})
)
context = await browser.new_context(viewport={'width': 390, 'height': 844}, timezone_id='UTC')
await context.tracing.start(screenshots=True, snapshots=True, sources=True)
await context.route('http://localhost:9099/**', api)
page = await context.new_page()
page.on('pageerror', lambda error: errors.append(str(error)))
page.on('console', lambda message: logs.append(f'{message.type}: {message.text}'))
failed = False
try:
await page.goto(f'{base_url}/#/pages/login')
await expect(page.locator('input[type="text"]')).to_be_visible(timeout=30000)
await expect(page.locator('input')).to_have_count(2, timeout=15000)
await page.locator('input[type="text"]').fill('ci-user')
await page.locator('input[type="password"]').fill('ci-test-password')
await page.locator('uni-button').filter(has_text=re.compile(r'登\s*录')).click()
await expect(page).to_have_url(re.compile(r'/pages/index'), timeout=30000)
await page.goto(f'{base_url}/#/pages/mine/info/index')
await expect(page.get_by_text(USER['nickName'], exact=True)).to_be_visible(timeout=30000)
await expect(page.get_by_text('2025-12-31 19:00:00', exact=True)).to_be_visible(timeout=15000)
profile_requests = [request for request in observed if request['path'] == '/system/user/profile']
assert profile_requests, '个人资料页未发送真实 API 请求'
assert all(request['authorization'] == f'Bearer {TOKEN}' for request in profile_requests)
assert all(request['timezone'] == 'America/New_York' for request in profile_requests)
assert not errors, errors
(output / 'result.json').write_text(
json.dumps({'framework': framework, 'ok': True, 'requests': observed}, ensure_ascii=False, indent=2),
encoding='utf-8',
)
print(f'{framework}: H5 login, persisted session and timezone profile PASS')
except BaseException:
failed = True
await page.screenshot(path=str(output / 'failure.png'), full_page=True)
(output / 'page.html').write_text(await page.content(), encoding='utf-8')
raise
finally:
(output / 'browser.log').write_text('\n'.join([*logs, *errors]), encoding='utf-8')
await context.tracing.stop(**({'path': str(output / 'trace.zip')} if failed else {}))
await context.close()
await browser.close()
def main() -> None:
"""
在独立临时端口启动 H5 静态服务,结束后关闭自身创建的资源。
:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('--framework', choices=('vue2', 'vue3'), required=True)
args = parser.parse_args()
distribution = ROOT / 'ruoyi-fastapi-frontend' / args.framework / 'mobile/dist/build/h5'
if not (distribution / 'index.html').is_file():
raise SystemExit(f'请先构建 H5:{distribution}')
output = ROOT / 'target/ci/mobile-smoke' / args.framework
output.mkdir(parents=True, exist_ok=True)
server = ThreadingHTTPServer(('127.0.0.1', 0), partial(SimpleHTTPRequestHandler, directory=str(distribution)))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
asyncio.run(run_smoke(args.framework, f'http://127.0.0.1:{server.server_port}', output))
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
if __name__ == '__main__':
main()