mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-14 21:07:36 +00:00
refactor: 统一多前端工程架构、插件配置与 CI (#131)
* refactor: 重构项目架构 * refactor: 重构ci和测试 * fix: 修复测试 * feat: 新增更多E2E测试 * fix: 修复E2E测试
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONFIGURATIONS = (
|
||||
('docker.env', 'docker-compose.my.yml', 'my'),
|
||||
('docker.env', 'docker-compose.pg.yml', 'pg'),
|
||||
('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.my.yml', 'my'),
|
||||
('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.pg.yml', 'pg'),
|
||||
)
|
||||
|
||||
|
||||
def validate_configuration(config: dict[str, Any], framework: str, engine: str, root: Path) -> None:
|
||||
"""
|
||||
核对 Compose 实际解析后的前后端框架及 nginx 来源。
|
||||
|
||||
:param config: docker compose config 的 JSON 结果
|
||||
:param framework: 本次选择的框架
|
||||
:param engine: my 或 pg
|
||||
:param root: 仓库根目录
|
||||
:return: None
|
||||
"""
|
||||
expected = (root / 'ruoyi-fastapi-frontend' / framework / 'web').resolve()
|
||||
services = config['services']
|
||||
frontend = services['ruoyi-frontend']
|
||||
build = frontend['build']
|
||||
if Path(build['context']).resolve() != expected:
|
||||
raise ValueError(f'{framework}: 前端构建目录不一致')
|
||||
if not (expected / build.get('dockerfile', 'Dockerfile')).is_file():
|
||||
raise ValueError(f'{framework}: 前端 Dockerfile 不存在')
|
||||
volumes = [
|
||||
volume for volume in frontend.get('volumes', []) if volume.get('target') == '/etc/nginx/conf.d/default.conf'
|
||||
]
|
||||
nginx = expected / 'bin' / f'nginx.docker{engine}.conf'
|
||||
if len(volumes) != 1 or Path(volumes[0]['source']).resolve() != nginx or not nginx.is_file():
|
||||
raise ValueError(f'{framework}: nginx 配置未选择同一框架')
|
||||
backend = services[f'ruoyi-backend-{engine}']
|
||||
if backend['environment'].get('RUOYI_PLUGIN_FRONTEND_FRAMEWORK') != framework:
|
||||
raise ValueError(f'{framework}: 后端插件框架与前端不一致')
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
使用两种框架解析全部生产和测试 Compose,无需启动容器。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--docker', default='docker')
|
||||
args = parser.parse_args()
|
||||
for framework in ('vue2', 'vue3'):
|
||||
for env_file, compose_file, engine in CONFIGURATIONS:
|
||||
result = subprocess.run(
|
||||
[args.docker, 'compose', '--env-file', env_file, '-f', compose_file, 'config', '--format', 'json'],
|
||||
cwd=ROOT,
|
||||
env={**os.environ, 'FRONTEND_FRAMEWORK': framework},
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
timeout=60,
|
||||
)
|
||||
validate_configuration(json.loads(result.stdout), framework, engine, ROOT)
|
||||
print(f'{framework}: {compose_file} PASS')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
import argparse
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_report(path: Path, *, allow_skips: bool = False) -> tuple[int, int]:
|
||||
"""
|
||||
验证测试确实执行,专用验收任务不允许缺少依赖而全部跳过。
|
||||
|
||||
:param path: pytest JUnit 报告路径
|
||||
:param allow_skips: 是否允许普通跨平台回归中的预期跳过
|
||||
:return: 实际执行及跳过的测试数量
|
||||
"""
|
||||
root = ET.parse(path).getroot()
|
||||
cases = root.findall('.//testcase')
|
||||
skipped = sum(case.find('skipped') is not None for case in cases)
|
||||
executed = len(cases) - skipped
|
||||
failures = sum(case.find('failure') is not None or case.find('error') is not None for case in cases)
|
||||
if executed == 0 or failures or (skipped and not allow_skips):
|
||||
raise ValueError(f'{path}: 执行 {executed},跳过 {skipped},失败 {failures}')
|
||||
return executed, skipped
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
检查报告并将执行数量写入 CI 摘要。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('report', type=Path)
|
||||
parser.add_argument('--allow-skips', action='store_true')
|
||||
args = parser.parse_args()
|
||||
executed, skipped = check_report(args.report, allow_skips=args.allow_skips)
|
||||
print(f'{args.report}: executed={executed}, skipped={skipped}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,245 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRAMEWORKS = ('vue2', 'vue3')
|
||||
PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13')
|
||||
CATEGORIES = ('quality', 'backend', 'frontend', 'integration', 'e2e', 'native')
|
||||
FRONTEND_FILE_PATH_PARTS = 4
|
||||
|
||||
|
||||
# 将影响范围规则集中列出,便于逐条审核;拆散分支会掩盖任务之间的联动。
|
||||
def build_plan(paths: list[str], *, full: bool = False) -> dict[str, Any]: # noqa: PLR0912, PLR0915
|
||||
"""
|
||||
根据源码、共享依赖及测试的影响范围生成 CI 执行计划。
|
||||
|
||||
:param paths: 变更文件,重命名需要同时传入旧路径和新路径
|
||||
:param full: 是否执行完整兼容矩阵
|
||||
:return: 分类开关、矩阵和必跑任务
|
||||
"""
|
||||
projects: set[tuple[str, str]] = set()
|
||||
browsers: set[str] = set()
|
||||
networks: set[str] = set()
|
||||
backend = native = database = release = False
|
||||
normalized = [path.replace('\\', '/').removeprefix('./') for path in paths]
|
||||
shared = full or any(
|
||||
path.startswith(('.github/', 'ruoyi-fastapi-backend/requirements'))
|
||||
or path
|
||||
in (
|
||||
'ruoyi-fastapi-backend/pyproject.toml',
|
||||
'ruoyi-fastapi-backend/tests/conftest.py',
|
||||
'ruoyi-fastapi-test/requirements.txt',
|
||||
'.gitignore',
|
||||
)
|
||||
for path in normalized
|
||||
)
|
||||
if shared:
|
||||
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
|
||||
browsers.update(FRAMEWORKS)
|
||||
networks.update(FRAMEWORKS)
|
||||
backend = native = database = release = True
|
||||
for path in normalized:
|
||||
if path.lower().endswith('.md'):
|
||||
continue
|
||||
parts = path.split('/')
|
||||
if path.startswith('ruoyi-fastapi-backend/'):
|
||||
backend = True
|
||||
relative = path.removeprefix('ruoyi-fastapi-backend/')
|
||||
if not relative.startswith(('tests/', 'ruff.toml')):
|
||||
browsers.update(FRAMEWORKS)
|
||||
if relative.startswith(('common/', 'config/', 'utils/', 'sql/', 'scripts/verify_timezone_database')):
|
||||
database = True
|
||||
if relative.startswith(
|
||||
(
|
||||
'plugins/',
|
||||
'module_plugin/',
|
||||
'cli/',
|
||||
'tests/plugins/',
|
||||
'tests/cli/',
|
||||
'tests/module_plugin/',
|
||||
'tests/sql/',
|
||||
)
|
||||
):
|
||||
native = release = True
|
||||
if relative == 'scripts/build_native_plugin.py':
|
||||
native = True
|
||||
if relative.startswith(
|
||||
('plugins/', 'module_plugin/', 'common/', 'config/', 'middlewares/', 'utils/')
|
||||
) or relative in ('server.py', 'app.py', 'module_admin/service/login_service.py'):
|
||||
networks.update(FRAMEWORKS)
|
||||
if relative.startswith(('config/', 'sql/', 'scripts/plugin_', 'tests/server/')) or relative in (
|
||||
'server.py',
|
||||
'app.py',
|
||||
):
|
||||
release = True
|
||||
if relative.startswith('tests/plugins/integration/') or relative in (
|
||||
'tests/scheduler_helpers.py',
|
||||
'tests/plugins/core/runtime/test_browser_session.py',
|
||||
):
|
||||
networks.update(FRAMEWORKS)
|
||||
if relative in (
|
||||
'plugins/core/frontend.py',
|
||||
'plugins/core/environment.py',
|
||||
'plugins/core/manifest/schema.py',
|
||||
):
|
||||
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
|
||||
if relative.startswith('plugins/examples/python/bundle_demo/web/'):
|
||||
projects.update((framework, 'web') for framework in FRAMEWORKS)
|
||||
elif (
|
||||
len(parts) >= FRONTEND_FILE_PATH_PARTS
|
||||
and parts[0] == 'ruoyi-fastapi-frontend'
|
||||
and parts[1] in FRAMEWORKS
|
||||
and parts[2] in ('web', 'mobile')
|
||||
):
|
||||
framework, target = parts[1:3]
|
||||
projects.add((framework, target))
|
||||
if target == 'web':
|
||||
browsers.add(framework)
|
||||
networks.add(framework)
|
||||
if target == 'web' and (parts[-1].startswith('pluginBridge') or parts[-1] == 'package.json'):
|
||||
backend = native = release = True
|
||||
elif path.startswith('ruoyi-fastapi-frontend/'):
|
||||
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
|
||||
browsers.update(FRAMEWORKS)
|
||||
networks.update(FRAMEWORKS)
|
||||
elif path.startswith('ruoyi-fastapi-test/frontend/'):
|
||||
projects.update((framework, 'mobile') for framework in FRAMEWORKS)
|
||||
elif path.startswith('ruoyi-fastapi-test/time-contract/'):
|
||||
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
|
||||
database = True
|
||||
browsers.update(FRAMEWORKS)
|
||||
elif path.startswith('ruoyi-fastapi-test/') or path in (
|
||||
'docker.env',
|
||||
'docker-compose.my.yml',
|
||||
'docker-compose.pg.yml',
|
||||
):
|
||||
browsers.update(FRAMEWORKS)
|
||||
if 'docker' in path or path.endswith('.sql'):
|
||||
database = True
|
||||
|
||||
required = {
|
||||
'quality': True,
|
||||
'backend': backend,
|
||||
'frontend': bool(projects),
|
||||
'integration': database or release or bool(networks),
|
||||
'e2e': bool(browsers),
|
||||
'native': native,
|
||||
}
|
||||
return {
|
||||
'full': full,
|
||||
'required': required,
|
||||
'frontend_matrix': {
|
||||
'include': [{'framework': framework, 'target': target} for framework, target in sorted(projects)]
|
||||
},
|
||||
'e2e_matrix': {
|
||||
'include': [
|
||||
{'framework': framework, 'database': engine, 'python': version}
|
||||
for framework in sorted(browsers)
|
||||
for engine in ('mysql', 'postgresql')
|
||||
for version in (PYTHON_VERSIONS if full else ('3.12',))
|
||||
]
|
||||
},
|
||||
'network_frameworks': sorted(networks),
|
||||
'database': database,
|
||||
'release': release,
|
||||
'paths': normalized,
|
||||
}
|
||||
|
||||
|
||||
def check_results(plan: dict[str, Any], needs: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
核验必跑任务,拒绝失败、取消、缺失和意外跳过。
|
||||
|
||||
:param plan: 选路任务生成的计划
|
||||
:param needs: GitHub needs 上下文
|
||||
:return: 不满足门禁的原因
|
||||
"""
|
||||
errors = []
|
||||
if needs.get('plan', {}).get('result') != 'success':
|
||||
errors.append('CI 计划未成功生成')
|
||||
required = plan.get('required', {})
|
||||
if set(required) != set(CATEGORIES) or not all(isinstance(value, bool) for value in required.values()):
|
||||
return [*errors, 'CI 计划缺少完整的任务分类']
|
||||
for category in CATEGORIES:
|
||||
result = needs.get(category, {}).get('result')
|
||||
allowed = ('success',) if required[category] else ('success', 'skipped')
|
||||
if result not in allowed:
|
||||
errors.append(f'{category}: {result or "missing"},预期 {"/".join(allowed)}')
|
||||
return errors
|
||||
|
||||
|
||||
def changed_paths(base: str) -> list[str]:
|
||||
"""
|
||||
从 Git 获取变更文件,保留删除及重命名前后的路径。
|
||||
|
||||
:param base: 已检出的比较基准提交
|
||||
:return: 相对仓库根目录的路径
|
||||
"""
|
||||
result = subprocess.run(
|
||||
['git', 'diff', '--no-renames', '--name-only', '-z', base, 'HEAD'],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
return [path for path in result.stdout.decode('utf-8').split('\0') if path]
|
||||
|
||||
|
||||
def write_outputs(plan: dict[str, Any]) -> None:
|
||||
"""
|
||||
输出 GitHub 任务参数及可审阅的执行摘要。
|
||||
|
||||
:param plan: 本次运行计划
|
||||
:return: None
|
||||
"""
|
||||
payload = {
|
||||
**plan['required'],
|
||||
**{key: value for key, value in plan.items() if key not in ('required', 'paths')},
|
||||
'plan_json': plan,
|
||||
}
|
||||
if output := os.environ.get('GITHUB_OUTPUT'):
|
||||
with Path(output).open('a', encoding='utf-8') as stream:
|
||||
for key, value in payload.items():
|
||||
stream.write(f'{key}={json.dumps(value, ensure_ascii=False, separators=(",", ":"))}\n')
|
||||
if summary := os.environ.get('GITHUB_STEP_SUMMARY'):
|
||||
with Path(summary).open('a', encoding='utf-8') as stream:
|
||||
stream.write('## CI 执行计划\n\n| 分类 | 执行 |\n|---|---|\n')
|
||||
for category, required in plan['required'].items():
|
||||
stream.write(f'| {category} | {"运行" if required else "无相关改动"} |\n')
|
||||
stream.write(f'\n完整矩阵:{plan["full"]}\n')
|
||||
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
执行变更选路或最终结果校验。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('command', choices=('plan', 'gate'))
|
||||
args = parser.parse_args()
|
||||
if args.command == 'gate':
|
||||
plan = json.loads(os.environ.get('PLAN_JSON') or '{}')
|
||||
needs = json.loads(os.environ.get('NEEDS_JSON') or '{}')
|
||||
errors = check_results(plan, needs)
|
||||
if errors:
|
||||
raise SystemExit('\n'.join(errors))
|
||||
print('所有应执行的 CI 分类均已通过。')
|
||||
return
|
||||
event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8'))
|
||||
event_name = os.environ['GITHUB_EVENT_NAME']
|
||||
full = event_name in ('push', 'schedule') or (
|
||||
str(event.get('inputs', {}).get('full', 'true')).lower() == 'true' and event_name == 'workflow_dispatch'
|
||||
)
|
||||
base = event.get('pull_request', {}).get('base', {}).get('sha', '')
|
||||
paths = changed_paths(base) if base else ['.github/workflows/ci.yml']
|
||||
write_outputs(build_plan(paths, full=full))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
从 AI 插件清单安装测试依赖,避免 CI 维护第二份版本列表。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
backend = Path(__file__).resolve().parents[2] / 'ruoyi-fastapi-backend'
|
||||
manifest = yaml.safe_load((backend / 'plugins/ai/plugin.yaml').read_text(encoding='utf-8'))
|
||||
requirements = manifest['dependencies']['python']
|
||||
if not requirements or not all(isinstance(item, str) and not item.startswith('-') for item in requirements):
|
||||
raise ValueError('AI 插件 Python 依赖清单无效')
|
||||
subprocess.run([sys.executable, '-m', 'pip', 'install', *requirements], check=True, timeout=900)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,184 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user