refactor: 统一多前端工程架构、插件配置与 CI (#131)

* refactor: 重构项目架构

* refactor: 重构ci和测试

* fix: 修复测试

* feat: 新增更多E2E测试

* fix: 修复E2E测试
This commit is contained in:
insistence
2026-10-10 21:15:22 +08:00
committed by GitHub
parent 4bd83bc7cd
commit 025b0822df
1167 changed files with 94684 additions and 5231 deletions
+72
View File
@@ -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()
+39
View File
@@ -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()
+245
View File
@@ -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()
+184
View File
@@ -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()