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
+3
View File
@@ -0,0 +1,3 @@
pytest==9.1.1
PyYAML==6.0.3
ruff==0.16.1
+2
View File
@@ -0,0 +1,2 @@
# CI 脚本和测试与后端使用相同的 Python 风格。
extend = "../ruoyi-fastapi-backend/ruff.toml"
+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()
+91
View File
@@ -0,0 +1,91 @@
import importlib.util
from copy import deepcopy
from pathlib import Path
from types import ModuleType
from typing import Any
import pytest
ROOT = Path(__file__).resolve().parents[2]
def load_script(name: str) -> ModuleType:
"""
按文件路径加载 CI 脚本,避免依赖仓库外的模块搜索路径。
:param name: 不含扩展名的脚本名称
:return: 已执行模块顶层定义的脚本模块
"""
spec = importlib.util.spec_from_file_location(name, ROOT / '.github/scripts' / f'{name}.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
compose = load_script('check_compose')
reports = load_script('check_test_report')
@pytest.fixture
def configuration() -> dict[str, Any]:
"""提供前端、nginx 和后端框架一致的 Compose 配置。"""
frontend = ROOT / 'ruoyi-fastapi-frontend/vue2/web'
return {
'services': {
'ruoyi-frontend': {
'build': {'context': str(frontend), 'dockerfile': 'Dockerfile'},
'volumes': [
{'source': str(frontend / 'bin/nginx.dockermy.conf'), 'target': '/etc/nginx/conf.d/default.conf'}
],
},
'ruoyi-backend-my': {'environment': {'RUOYI_PLUGIN_FRONTEND_FRAMEWORK': 'vue2'}},
}
}
def test_compose_accepts_consistent_framework(configuration: dict[str, Any]) -> None:
"""框架一致的 Compose 配置可以通过校验。"""
compose.validate_configuration(configuration, 'vue2', 'my', ROOT)
@pytest.mark.parametrize('component', ['build', 'nginx', 'backend'])
def test_compose_rejects_mixed_framework_deployment(configuration: dict[str, Any], component: str) -> None:
"""前端、nginx 或后端混用框架时拒绝部署配置。"""
invalid = deepcopy(configuration)
if component == 'build':
invalid['services']['ruoyi-frontend']['build']['context'] = str(ROOT / 'ruoyi-fastapi-frontend/vue3/web')
elif component == 'nginx':
invalid['services']['ruoyi-frontend']['volumes'][0]['source'] = str(
ROOT / 'ruoyi-fastapi-frontend/vue3/web/bin/nginx.dockermy.conf'
)
else:
invalid['services']['ruoyi-backend-my']['environment']['RUOYI_PLUGIN_FRONTEND_FRAMEWORK'] = 'vue3'
with pytest.raises(ValueError):
compose.validate_configuration(invalid, 'vue2', 'my', ROOT)
@pytest.mark.parametrize(
'cases',
['', '<testcase><skipped /></testcase>', '<testcase><failure /></testcase>', '<testcase><error /></testcase>'],
)
def test_report_rejects_empty_skipped_or_failed_verification(tmp_path: Path, cases: str) -> None:
"""空报告、全部跳过和失败用例都不能视为验收成功。"""
report = tmp_path / 'results.xml'
report.write_text(f'<testsuites><testsuite>{cases}</testsuite></testsuites>', encoding='utf-8')
with pytest.raises(ValueError):
reports.check_report(report)
def test_expected_platform_skip_still_requires_executed_tests(tmp_path: Path) -> None:
"""允许预期平台跳过时仍要求实际执行测试。"""
report = tmp_path / 'results.xml'
report.write_text(
'<testsuites><testsuite><testcase/><testcase><skipped/></testcase></testsuite></testsuites>', encoding='utf-8'
)
assert reports.check_report(report, allow_skips=True) == (1, 1)
def test_missing_report_is_not_success(tmp_path: Path) -> None:
"""报告缺失时不能视为验收成功。"""
with pytest.raises(FileNotFoundError):
reports.check_report(tmp_path / 'missing.xml')
+169
View File
@@ -0,0 +1,169 @@
import importlib.util
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'ci_plan.py'
SPEC = importlib.util.spec_from_file_location('ci_plan', SCRIPT)
ci = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ci)
FRONTEND_PROJECT_COUNT = 4
STANDARD_E2E_COMBINATION_COUNT = 4
FULL_E2E_COMBINATION_COUNT = 16
def test_readme_change_only_runs_quality() -> None:
"""仅文档变更时只执行质量检查。"""
assert ci.build_plan(['README.md'])['required'] == {
'quality': True,
'backend': False,
'frontend': False,
'integration': False,
'e2e': False,
'native': False,
}
@pytest.mark.parametrize(
'path',
[
'ruoyi-fastapi-backend/cli/groups/plugin/options.py',
'ruoyi-fastapi-backend/cli/groups/plugin/controller.py',
'ruoyi-fastapi-backend/cli/runtime/plugin/service.py',
'ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py',
'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py',
],
)
def test_shared_plugin_changes_retain_windows_and_release_coverage(path: str) -> None:
"""共享插件变更保留原生模块及发布验收覆盖。"""
plan = ci.build_plan([path])
assert plan['required']['backend']
assert plan['required']['native']
assert plan['release']
def test_native_builder_change_runs_native_verification() -> None:
"""原生构建器变更必须触发原生验收。"""
plan = ci.build_plan(['ruoyi-fastapi-backend/scripts/build_native_plugin.py'])
assert plan['required']['native']
def test_bundle_example_change_runs_both_web_consumers() -> None:
"""示例开发代理由两版 Web 测试直接引用,变更时均应执行。"""
plan = ci.build_plan(['ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/mockRequest.js'])
assert plan['frontend_matrix'] == {
'include': [
{'framework': 'vue2', 'target': 'web'},
{'framework': 'vue3', 'target': 'web'},
]
}
@pytest.mark.parametrize(
'path',
[
'ruoyi-fastapi-backend/module_admin/service/login_service.py',
'ruoyi-fastapi-backend/tests/scheduler_helpers.py',
'ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py',
],
)
def test_shared_auth_and_test_helpers_keep_both_network_suites(path: str) -> None:
"""共享鉴权和测试夹具变更触发两版网络验收。"""
plan = ci.build_plan([path])
assert plan['required']['backend']
assert plan['required']['integration']
assert plan['network_frameworks'] == ['vue2', 'vue3']
@pytest.mark.parametrize(
'path', ['.github/workflows/ci.yml', '.github/scripts/ci_plan.py', 'ruoyi-fastapi-backend/requirements-test.txt']
)
def test_ci_and_dependency_changes_select_every_category(path: str) -> None:
"""CI 及共享依赖变更覆盖全部任务分类。"""
plan = ci.build_plan([path])
assert all(plan['required'].values())
assert len(plan['frontend_matrix']['include']) == FRONTEND_PROJECT_COUNT
assert len(plan['e2e_matrix']['include']) == STANDARD_E2E_COMBINATION_COUNT
def test_mobile_change_does_not_build_other_projects_or_web_e2e() -> None:
"""单个移动端变更只选中对应工程。"""
plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js'])
assert plan['frontend_matrix'] == {'include': [{'framework': 'vue2', 'target': 'mobile'}]}
assert not plan['required']['e2e']
@pytest.mark.parametrize(
'path',
[
'ruoyi-fastapi-test/frontend/mobile-harness.mjs',
'ruoyi-fastapi-test/frontend/mobile-behavior.mjs',
],
)
def test_shared_mobile_harness_runs_both_mobile_projects(path: str) -> None:
"""共享移动端测试工具变更同时验证两版工程。"""
plan = ci.build_plan([path])
assert plan['required']['frontend']
assert plan['frontend_matrix'] == {
'include': [
{'framework': 'vue2', 'target': 'mobile'},
{'framework': 'vue3', 'target': 'mobile'},
]
}
assert not plan['required']['e2e']
def test_web_change_selects_matching_framework_and_both_databases() -> None:
"""Web 变更使用对应框架并覆盖两类数据库。"""
plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/web/src/views/login.vue'])
assert plan['network_frameworks'] == ['vue2']
assert {(row['framework'], row['database'], row['python']) for row in plan['e2e_matrix']['include']} == {
('vue2', 'mysql', '3.12'),
('vue2', 'postgresql', '3.12'),
}
def test_shared_time_contract_runs_all_frontends_and_database_checks() -> None:
"""共享时间契约触发全部前端及数据库检查。"""
plan = ci.build_plan(['ruoyi-fastapi-test/time-contract/fixtures.json'])
assert len(plan['frontend_matrix']['include']) == FRONTEND_PROJECT_COUNT
assert plan['database']
def test_framework_move_keeps_both_sides_in_plan() -> None:
"""跨框架移动文件保留来源和目标两侧验证。"""
plan = ci.build_plan(
['ruoyi-fastapi-frontend/vue2/web/src/utils/demo.js', 'ruoyi-fastapi-frontend/vue3/web/src/utils/demo.js']
)
assert plan['network_frameworks'] == ['vue2', 'vue3']
def test_full_run_preserves_python_database_and_framework_combinations() -> None:
"""完整运行保留 Python、数据库和框架的全部组合。"""
plan = ci.build_plan([], full=True)
assert all(plan['required'].values())
assert len(plan['e2e_matrix']['include']) == FULL_E2E_COMBINATION_COUNT
assert {row['python'] for row in plan['e2e_matrix']['include']} == {'3.10', '3.11', '3.12', '3.13'}
@pytest.mark.parametrize('result', ['failure', 'cancelled', 'skipped', None])
def test_gate_rejects_unsuccessful_required_job(result: str | None) -> None:
"""必跑任务失败、取消、跳过或缺失均不能通过门禁。"""
plan = ci.build_plan(['README.md'])
needs = {'plan': {'result': 'success'}, **{name: {'result': 'skipped'} for name in ci.CATEGORIES}}
needs['quality']['result'] = result
assert ci.check_results(plan, needs)
def test_gate_allows_only_unselected_jobs_to_skip() -> None:
"""只有未选中的任务允许跳过。"""
plan = ci.build_plan(['README.md'])
needs = {'plan': {'result': 'success'}, **{name: {'result': 'skipped'} for name in ci.CATEGORIES}}
needs['quality']['result'] = 'success'
assert ci.check_results(plan, needs) == []
def test_gate_fails_closed_when_plan_or_job_is_missing() -> None:
"""缺少执行计划或任务结果时门禁必须拒绝放行。"""
assert ci.check_results({}, {})
assert ci.check_results(ci.build_plan(['README.md']), {'plan': {'result': 'success'}})
-64
View File
@@ -1,64 +0,0 @@
name: Backend Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
pytest:
runs-on: ubuntu-latest
env:
# Typer otherwise forces ANSI help output when GITHUB_ACTIONS is set.
_TYPER_FORCE_DISABLE_TERMINAL: "1"
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: |
ruoyi-fastapi-backend/requirements.txt
ruoyi-fastapi-backend/plugins/ai/plugin.yaml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pytest pytest-asyncio aiosqlite
- name: Install AI plugin dependencies
run: |
pip install \
agno==2.8.6 \
anthropic==0.120.2 \
cerebras-cloud-sdk==1.91.0 \
cohere==7.0.8 \
google-genai==2.16.0 \
groq==1.6.0 \
litellm==1.94.0 \
llama-api-client==0.6.0 \
mistralai==2.8.0 \
ollama==0.6.2 \
openai==2.52.0 \
portkey-ai==2.3.4
- name: Run backend tests
run: pytest -q
- name: Check timezone behavior under independent host timezones
run: |
for zone in UTC Asia/Shanghai America/New_York; do
TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py
done
+110
View File
@@ -0,0 +1,110 @@
name: CI / Backend
on:
workflow_call:
permissions:
contents: read
env:
_TYPER_FORCE_DISABLE_TERMINAL: '1'
PYTHONUTF8: '1'
jobs:
unit-contract:
name: Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: |
ruoyi-fastapi-backend/requirements*.txt
ruoyi-fastapi-backend/plugins/ai/plugin.yaml
- name: Install test dependencies
run: python -m pip install -r requirements.txt -r requirements-test.txt
- name: Install AI plugin dependencies from its manifest
run: python ../.github/scripts/install_ai_test_dependencies.py
- name: Run backend unit and contract tests
run: python -m pytest tests -q -m 'not browser and not native and not integration' --strict-markers --durations=20 --junitxml=target/ci/backend.xml
- name: Verify tests were executed
run: python ../.github/scripts/check_test_report.py target/ci/backend.xml --allow-skips
- uses: actions/upload-artifact@v6
if: always()
with:
name: backend-python-${{ matrix.python }}
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
windows-contract:
name: Windows CLI and paths
runs-on: windows-latest
timeout-minutes: 20
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-test.txt
- name: Verify CLI, paths, artifacts and portable runtime contracts
run: >-
python -m pytest tests/cli
tests/plugins/core/test_frontend_layout.py tests/plugins/core/validation/test_frontend_dependencies.py
tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py
tests/plugins/core/artifacts tests/plugins/core/deployment
tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py
tests/module_plugin/controller/test_plugin_release_controller.py tests/sql/test_plugin_release_schema.py
tests/plugins/examples/test_task_demo.py
-m 'not native and not browser and not integration' -q --strict-markers --durations=15 --junitxml=target/ci/windows-contract.xml
- run: python ../.github/scripts/check_test_report.py target/ci/windows-contract.xml --allow-skips
- uses: actions/upload-artifact@v6
if: always()
with:
name: backend-windows-contract
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
timezone:
name: Host timezone contracts
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-test.txt
- name: Verify UTC, Shanghai and New York in separate processes
shell: bash
run: |
for zone in UTC Asia/Shanghai America/New_York; do
report="target/ci/timezone-${zone//\//_}.xml"
TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py --junitxml="$report"
python ../.github/scripts/check_test_report.py "$report"
done
- uses: actions/upload-artifact@v6
if: always()
with:
name: backend-timezones
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
+111
View File
@@ -0,0 +1,111 @@
name: CI / E2E
on:
workflow_call:
inputs:
matrix_json:
type: string
required: true
permissions:
contents: read
jobs:
browser:
name: ${{ matrix.framework }} / ${{ matrix.database }} / Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.matrix_json) }}
env:
COMPOSE_PROJECT_NAME: ruoyi-ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }}
COMPOSE_FILE: ${{ github.workspace }}/ruoyi-fastapi-test/target/e2e/compose.json
FRONTEND_FRAMEWORK: ${{ matrix.framework }}
E2E_OUTPUT_DIR: ${{ github.workspace }}/ruoyi-fastapi-test/target/e2e/browser
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-test/requirements.txt
- name: Install test runner and Chromium
run: |
python -m pip install -r ruoyi-fastapi-test/requirements.txt
python -m playwright install --with-deps chromium
- name: Verify reusable browser and isolated contexts
working-directory: ruoyi-fastapi-test
run: |
python -m pytest -m harness -q --junitxml=target/e2e/harness.xml
python ../.github/scripts/check_test_report.py target/e2e/harness.xml
- name: Generate isolated Compose project
run: |
python ruoyi-fastapi-test/prepare_e2e_compose.py \
--database '${{ matrix.database }}' --framework '${{ matrix.framework }}' \
--python-version '${{ matrix.python }}' --project "$COMPOSE_PROJECT_NAME" \
--output "$COMPOSE_FILE"
docker compose config --quiet
- uses: docker/setup-buildx-action@v3
- name: Build frontend with reusable layers
uses: docker/build-push-action@v6
with:
context: ruoyi-fastapi-frontend/${{ matrix.framework }}/web
file: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/Dockerfile
load: true
tags: ${{ env.COMPOSE_PROJECT_NAME }}-ruoyi-frontend:ci
cache-from: type=gha,scope=e2e-frontend-${{ matrix.framework }}
cache-to: type=gha,scope=e2e-frontend-${{ matrix.framework }},mode=max
- name: Select database Dockerfile
id: database
env:
DATABASE: ${{ matrix.database }}
run: |
if [ "$DATABASE" = mysql ]; then
echo 'suffix=my' >> "$GITHUB_OUTPUT"
else
echo 'suffix=pg' >> "$GITHUB_OUTPUT"
fi
- name: Build backend with reusable layers
uses: docker/build-push-action@v6
with:
context: ruoyi-fastapi-backend
file: ruoyi-fastapi-backend/Dockerfile.${{ steps.database.outputs.suffix }}
load: true
tags: ${{ env.COMPOSE_PROJECT_NAME }}-ruoyi-backend-${{ steps.database.outputs.suffix }}:ci
build-args: |
PYTHON_VERSION=${{ matrix.python }}
PIP_INDEX_URL=https://pypi.org/simple
cache-from: type=gha,scope=e2e-backend-${{ matrix.database }}-py${{ matrix.python }}
cache-to: type=gha,scope=e2e-backend-${{ matrix.database }}-py${{ matrix.python }},mode=max
- name: Wait for healthy database, API and frontend proxy
run: |
docker compose up --detach --no-build --wait --wait-timeout 240
echo "TEST_FRONTEND_URL=http://$(docker compose port ruoyi-frontend 80)" >> "$GITHUB_ENV"
echo "TEST_BACKEND_URL=http://$(docker compose port ruoyi-backend-${{ steps.database.outputs.suffix }} 9099)" >> "$GITHUB_ENV"
- name: Run all application pages and functional E2E checks
working-directory: ruoyi-fastapi-test
run: |
python -m pytest -m e2e -v --durations=20 --junitxml=target/e2e/junit.xml
python ../.github/scripts/check_test_report.py target/e2e/junit.xml
- name: Collect service diagnostics
if: always()
run: |
mkdir -p ruoyi-fastapi-test/target/e2e
if [ -f "$COMPOSE_FILE" ]; then
docker compose ps --all > ruoyi-fastapi-test/target/e2e/services.txt 2>&1 || true
docker compose logs --no-color --timestamps > ruoyi-fastapi-test/target/e2e/services.log 2>&1 || true
fi
- name: Remove only this test project's containers and data
if: always()
run: |
if [ -f "$COMPOSE_FILE" ]; then
docker compose down --volumes --remove-orphans --timeout 20
fi
- uses: actions/upload-artifact@v6
if: always()
with:
name: e2e-${{ matrix.framework }}-${{ matrix.database }}-py${{ matrix.python }}
path: ruoyi-fastapi-test/target/e2e
retention-days: 7
if-no-files-found: warn
+116
View File
@@ -0,0 +1,116 @@
name: CI / Frontend
on:
workflow_call:
inputs:
matrix_json:
description: 'JSON matrix with framework and target entries under include'
required: true
type: string
full:
description: 'Reserved for callers requesting the full project matrix'
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
frontend:
name: ${{ matrix.framework }} / ${{ matrix.target }}
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.matrix_json) }}
env:
NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }}
defaults:
run:
working-directory: ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install the Vue 2 Mobile package manager
if: matrix.framework == 'vue2' && matrix.target == 'mobile'
run: npm install --global yarn@1.22.22
- uses: pnpm/action-setup@v5
if: matrix.framework == 'vue3' && matrix.target == 'mobile'
with:
package_json_file: ruoyi-fastapi-frontend/vue3/mobile/package.json
- name: Locate the package cache
id: package-cache
env:
FRAMEWORK: ${{ matrix.framework }}
TARGET: ${{ matrix.target }}
run: |
if [ "$TARGET" = web ]; then
echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT"
elif [ "$FRAMEWORK" = vue2 ]; then
echo "path=$(yarn cache dir --silent)" >> "$GITHUB_OUTPUT"
else
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
fi
- uses: actions/cache@v5
with:
path: ${{ steps.package-cache.outputs.path }}
# Lockfiles are intentionally not tracked in these projects.
key: ${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}-${{ hashFiles(format('ruoyi-fastapi-frontend/{0}/{1}/package.json', matrix.framework, matrix.target)) }}
restore-keys: |
${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}-
- name: Install Web dependencies
if: matrix.target == 'web'
run: npm install --no-audit --no-fund --package-lock=false
- name: Install Vue 2 Mobile dependencies
if: matrix.framework == 'vue2' && matrix.target == 'mobile'
run: yarn install --non-interactive
- name: Install Vue 3 Mobile dependencies
if: matrix.framework == 'vue3' && matrix.target == 'mobile'
run: pnpm install --no-frozen-lockfile
- name: Run unit and component behavior tests
run: npm run test:unit
- name: Verify time contracts in three host timezones
run: |
for zone in UTC Asia/Shanghai America/New_York; do
TZ="$zone" npm run test:contract
done
- name: Verify the Vue 2 Mobile browser bundle without native Intl
if: matrix.framework == 'vue2' && matrix.target == 'mobile'
run: npm run test:bundle
- name: Build Web production and Docker targets
if: matrix.target == 'web'
run: |
npm run build:prod
npm run build:docker
- name: Build Mobile H5
if: matrix.target == 'mobile'
run: npm run build:h5
- uses: actions/setup-python@v6
if: matrix.target == 'mobile'
with:
python-version: '3.12'
- name: Install the Mobile smoke browser
if: matrix.target == 'mobile'
run: |
python -m pip install playwright==1.62.0
python -m playwright install --with-deps chromium
- name: Verify Mobile H5 login and profile
if: matrix.target == 'mobile'
working-directory: .
run: python .github/scripts/mobile_smoke.py --framework ${{ matrix.framework }}
- name: Build Mobile WeChat target
if: matrix.target == 'mobile'
run: npm run build:mp-weixin
- name: Upload frontend diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: frontend-${{ matrix.framework }}-${{ matrix.target }}
path: |
target/ci/mobile-smoke/${{ matrix.framework }}/
ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}/test-results/
if-no-files-found: ignore
retention-days: 7
+218
View File
@@ -0,0 +1,218 @@
name: CI / Integration
on:
workflow_call:
inputs:
database:
type: boolean
default: false
release:
type: boolean
default: false
network_frameworks:
type: string
default: '[]'
full:
type: boolean
default: false
permissions:
contents: read
jobs:
database-timezone:
name: MySQL and PostgreSQL time contracts
if: inputs.database
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TZ: America/New_York
PYTHONUTF8: '1'
defaults:
run:
working-directory: ruoyi-fastapi-backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: timezone_test
TZ: Asia/Shanghai
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -ptimezone_test"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: timezone_test
TZ: America/New_York
PGTZ: America/New_York
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-pg.txt -r requirements-test.txt
- name: Configure isolated database verification
run: |
mkdir -p target/ci/timezone
python - <<'PY'
import json
from pathlib import Path
for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]:
source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username,
db_password='timezone_test', db_database=database)
Path(f'target/ci/timezone/{dialect}.json').write_text(json.dumps(source))
PY
- name: Verify drivers, CRUD and scheduler persistence
run: |
python -m scripts.verify_timezone_database --config target/ci/timezone/mysql.json --report target/ci/timezone/mysql-report.json
python -m scripts.verify_timezone_database --config target/ci/timezone/postgresql.json --report target/ci/timezone/postgresql-report.json
- uses: actions/upload-artifact@v6
if: always()
with:
name: integration-database-timezones
path: ruoyi-fastapi-backend/target/ci/timezone/*-report.json
retention-days: 7
task-bundle:
name: Task plugin bundle
if: inputs.release
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/package.json
- run: npm install --no-audit --no-fund --package-lock=false
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v6
with:
name: integration-task-bundle
path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dist/
if-no-files-found: error
retention-days: 1
release:
name: Plugin maintenance / Python ${{ matrix.python }}
needs: task-bundle
if: inputs.release
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python: ${{ fromJSON(inputs.full && '["3.10","3.11","3.12","3.13"]' || '["3.12"]') }}
defaults:
run:
working-directory: ruoyi-fastapi-backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ci-only-mysql
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -pci-only-mysql"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: ci-only-postgres
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
redis:
image: redis:6.2
ports: ['6379:6379']
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s --health-timeout=5s --health-retries=12
env:
RUOYI_PLUGIN_IT_ALLOW: '1'
RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql
RUOYI_IT_PG_PASSWORD: ci-only-postgres
RUOYI_IT_REDIS_PASSWORD: ''
PYTHONUTF8: '1'
_TYPER_FORCE_DISABLE_TERMINAL: '1'
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v6
with:
name: integration-task-bundle
path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dist
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-pg.txt
- name: Verify MySQL and PostgreSQL maintenance releases
shell: bash
run: |
mkdir -p target/ci/release
python scripts/plugin_release_integration.py --engine mysql 2>&1 | tee target/ci/release/mysql.log
python scripts/plugin_release_integration.py --engine postgresql 2>&1 | tee target/ci/release/postgresql.log
- uses: actions/upload-artifact@v6
if: always()
with:
name: integration-release-${{ matrix.python }}
path: ruoyi-fastapi-backend/target/ci/release/
retention-days: 7
network:
name: ${{ matrix.framework }} proxy and plugin sessions
if: inputs.network_frameworks != '[]'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
framework: ${{ fromJSON(inputs.network_frameworks) }}
defaults:
run:
working-directory: ruoyi-fastapi-backend
env:
RUOYI_PLUGIN_NETWORK_SMOKE: '1'
RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${{ matrix.framework }}
NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }}
DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}'
JWT_SECRET_KEY: isolated-plugin-network-smoke-ci-only
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/package.json
- run: python -m pip install -r requirements.txt -r requirements-test.txt playwright==1.62.0
- run: python -m playwright install --with-deps chromium
- run: npm --prefix ../ruoyi-fastapi-frontend/${{ matrix.framework }}/web install --no-audit --no-fund --package-lock=false
- name: Verify HTTPS proxy, iframe sessions and long connections
run: python -m pytest tests/plugins/integration/test_network_smoke.py -m browser --strict-markers -v --junitxml=target/plugin-network-smoke/${{ matrix.framework }}/results.xml
- run: python ../.github/scripts/check_test_report.py target/plugin-network-smoke/${{ matrix.framework }}/results.xml
- uses: actions/upload-artifact@v6
if: always()
with:
name: integration-network-${{ matrix.framework }}
path: ruoyi-fastapi-backend/target/plugin-network-smoke/${{ matrix.framework }}/
retention-days: 7
+80
View File
@@ -0,0 +1,80 @@
name: CI / Native
on:
workflow_call:
permissions:
contents: read
env:
PYTHONUTF8: '1'
_TYPER_FORCE_DISABLE_TERMINAL: '1'
jobs:
rust-quality:
name: Rust format and clippy
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy
- run: rustup default 1.99.0
- uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/target
key: rust-quality-${{ runner.os }}-1.99.0-${{ hashFiles('**/Cargo.lock') }}
- run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check
- run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings
runtime:
name: ${{ matrix.os }} / Python ${{ matrix.python }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.10', '3.11', '3.12', '3.13']
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-test.txt 'maturin>=1.15,<2'
- run: rustup toolchain install 1.99.0 --profile minimal
- run: rustup default 1.99.0
- uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/target
key: rust-native-${{ runner.os }}-${{ matrix.python }}-1.99.0-${{ hashFiles('**/Cargo.lock') }}
- name: Build and assemble source-free plugin
run: python scripts/build_native_plugin.py --output target/native-package
- name: Exercise real native runtime and signed artifact loading
env:
RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo
run: python -m pytest tests/plugins/native tests/plugins/core/deployment -m native --strict-markers -q --durations=15 --junitxml=target/ci/native.xml
- name: Require native tests to run without missing-artifact skips
run: python ../.github/scripts/check_test_report.py target/ci/native.xml
- uses: actions/upload-artifact@v6
if: always()
with:
name: native-${{ runner.os }}-${{ matrix.python }}
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
+54
View File
@@ -0,0 +1,54 @@
name: CI / Quality
on:
workflow_call:
permissions:
contents: read
jobs:
checks:
name: Code and CI contracts
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/requirements.txt
- run: python -m pip install -r .github/requirements.txt
- name: Python lint and format
run: |
ruff check ruoyi-fastapi-backend ruoyi-fastapi-test .github/scripts .github/tests
ruff format --check ruoyi-fastapi-backend ruoyi-fastapi-test .github/scripts .github/tests
- name: Test CI routing, failure gates and deployment contracts
run: python -m pytest .github/tests -q --junitxml=target/ci/quality.xml
- name: Validate every Compose framework selection
run: python .github/scripts/check_compose.py
- name: Install actionlint
env:
ACTIONLINT_VERSION: '1.7.12'
run: |
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl --fail --location --retry 3 "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" --output "${RUNNER_TEMP}/${archive}"
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${RUNNER_TEMP}/${archive}" | sha256sum --check
tar -xzf "${RUNNER_TEMP}/${archive}" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" -color
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/*/web/package.json
- name: Check Web formatting with the existing project configurations
run: |
for framework in vue2 vue3; do
(cd "ruoyi-fastapi-frontend/$framework/web" && npx --yes prettier@3.6.2 --check .)
done
- uses: actions/upload-artifact@v6
if: always()
with:
name: quality-reports
path: target/ci/
retention-days: 7
+106
View File
@@ -0,0 +1,106 @@
name: CI
on:
pull_request:
branches: [master]
push:
branches: [master]
schedule:
- cron: '21 18 * * *'
workflow_dispatch:
inputs:
full:
description: Run the full compatibility and end-to-end matrix
type: boolean
default: true
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
plan:
name: Plan
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
backend: ${{ steps.plan.outputs.backend }}
frontend: ${{ steps.plan.outputs.frontend }}
integration: ${{ steps.plan.outputs.integration }}
e2e: ${{ steps.plan.outputs.e2e }}
native: ${{ steps.plan.outputs.native }}
full: ${{ steps.plan.outputs.full }}
frontend_matrix: ${{ steps.plan.outputs.frontend_matrix }}
e2e_matrix: ${{ steps.plan.outputs.e2e_matrix }}
network_frameworks: ${{ steps.plan.outputs.network_frameworks }}
database: ${{ steps.plan.outputs.database }}
release: ${{ steps.plan.outputs.release }}
plan_json: ${{ steps.plan.outputs.plan_json }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: plan
run: python .github/scripts/ci_plan.py plan
quality:
name: Quality
needs: plan
uses: ./.github/workflows/ci-quality.yml
backend:
name: Backend
needs: plan
if: needs.plan.outputs.backend == 'true'
uses: ./.github/workflows/ci-backend.yml
frontend:
name: Frontend
needs: plan
if: needs.plan.outputs.frontend == 'true'
uses: ./.github/workflows/ci-frontend.yml
with:
matrix_json: ${{ needs.plan.outputs.frontend_matrix }}
full: ${{ needs.plan.outputs.full == 'true' }}
integration:
name: Integration
needs: plan
if: needs.plan.outputs.integration == 'true'
uses: ./.github/workflows/ci-integration.yml
with:
database: ${{ needs.plan.outputs.database == 'true' }}
release: ${{ needs.plan.outputs.release == 'true' }}
network_frameworks: ${{ needs.plan.outputs.network_frameworks }}
full: ${{ needs.plan.outputs.full == 'true' }}
e2e:
name: E2E
needs: plan
if: needs.plan.outputs.e2e == 'true'
uses: ./.github/workflows/ci-e2e.yml
with:
matrix_json: ${{ needs.plan.outputs.e2e_matrix }}
native:
name: Native
needs: plan
if: needs.plan.outputs.native == 'true'
uses: ./.github/workflows/ci-native.yml
result:
name: Result
if: always()
needs: [plan, quality, backend, frontend, integration, e2e, native]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- name: Require every selected category to succeed
env:
PLAN_JSON: ${{ needs.plan.outputs.plan_json }}
NEEDS_JSON: ${{ toJSON(needs) }}
run: python .github/scripts/ci_plan.py gate
-97
View File
@@ -1,97 +0,0 @@
name: Native Plugin
on:
push:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/core/**'
- 'ruoyi-fastapi-backend/plugins/examples/rust/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**'
- 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py'
- 'ruoyi-fastapi-backend/scripts/build_native_plugin.py'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/command.py'
- 'ruoyi-fastapi-frontend/src/utils/pluginBridge*'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py'
- 'ruoyi-fastapi-backend/tests/plugins/**'
- '.github/workflows/native-plugin.yml'
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/core/**'
- 'ruoyi-fastapi-backend/plugins/examples/rust/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**'
- 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py'
- 'ruoyi-fastapi-backend/scripts/build_native_plugin.py'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/command.py'
- 'ruoyi-fastapi-frontend/src/utils/pluginBridge*'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py'
- 'ruoyi-fastapi-backend/tests/plugins/**'
- '.github/workflows/native-plugin.yml'
workflow_dispatch:
jobs:
native:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.10', '3.11', '3.12', '3.13']
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements.txt
- name: Install host and build dependencies
run: python -m pip install -r requirements.txt 'maturin>=1.15,<2' pytest pytest-asyncio aiosqlite
- name: Install Rust
run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy
- name: Select Rust
run: rustup default 1.99.0
- name: Check Rust formatting
run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check
- name: Verify v1 and v2 plugin scaffolds
run: python -m pytest tests/cli/runtime/plugin/test_runtime_scaffold.py tests/cli/runtime/plugin/test_runtime_scaffold_v2.py -q
- name: Verify SDK provenance and offline update commands
run: python -m pytest tests/cli/runtime/plugin/test_runtime_sdk.py tests/cli/root/test_plugin_sdk_commands.py -q
- name: Verify persistent task plugin
run: python -m pytest tests/plugins/examples/test_task_demo.py -q
- name: Build and assemble source-free plugin
run: python scripts/build_native_plugin.py --output target/native-package
- name: Check Rust code
run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings
- name: Exercise native library through the host runtime
env:
RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo
run: python -m pytest tests/plugins/native tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py -q
- name: Verify signed artifacts and maintenance releases
env:
RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo
run: python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py tests/module_plugin/controller/test_plugin_release_controller.py tests/cli/root/test_plugin_artifact_commands.py tests/cli/root/test_plugin_release_wait.py tests/sql/test_plugin_release_schema.py -q
-174
View File
@@ -1,174 +0,0 @@
name: Playwright Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
mysql-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.docker_cache
key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create temporary Dockerfile with Python ${{ matrix.python-version }}
run: |
# Save original Dockerfile.my
cp ruoyi-fastapi-backend/Dockerfile.my ruoyi-fastapi-backend/Dockerfile.my.bak
# Create temporary Dockerfile with target Python version and default PyPI index
sed -e "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" \
-e 's| -i https://pypi.tuna.tsinghua.edu.cn/simple||g' \
ruoyi-fastapi-backend/Dockerfile.my.bak > ruoyi-fastapi-backend/Dockerfile.my
- name: Start services with docker-compose
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.my.yml up -d --build
- name: Wait for services to be ready
run: |
cd ruoyi-fastapi-test
# Wait for backend to be running
timeout 180 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-backend-my | grep -q "Up"; do sleep 5; done'
# Wait for frontend to be running
timeout 120 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done'
# Additional wait for services to be fully ready and listening on ports
sleep 30
# Check that backend is actually responding on the API endpoint
echo "Checking if backend service is ready..."
for i in {1..30}; do
if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service is ready!"
break
fi
echo "Waiting for backend service to be ready... ($i/30)"
sleep 5
done
# Final check
if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service failed to start properly. Checking logs..."
docker logs ruoyi-backend-my-test
exit 1
fi
- name: Run tests
run: |
cd ruoyi-fastapi-test
pip install -r requirements.txt
playwright install
pytest -v
- name: Stop services
if: always()
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.my.yml down
- name: Restore original Dockerfile.my
if: always()
run: |
# Restore original Dockerfile.my after test
mv ruoyi-fastapi-backend/Dockerfile.my.bak ruoyi-fastapi-backend/Dockerfile.my
pg-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.docker_cache
key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create temporary Dockerfile with Python ${{ matrix.python-version }}
run: |
# Save original Dockerfile.pg
cp ruoyi-fastapi-backend/Dockerfile.pg ruoyi-fastapi-backend/Dockerfile.pg.bak
# Create temporary Dockerfile with target Python version and default PyPI index
sed -e "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" \
-e 's| -i https://pypi.tuna.tsinghua.edu.cn/simple||g' \
ruoyi-fastapi-backend/Dockerfile.pg.bak > ruoyi-fastapi-backend/Dockerfile.pg
- name: Start services with docker-compose
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.pg.yml up -d --build
- name: Wait for services to be ready
run: |
cd ruoyi-fastapi-test
# Wait for backend to be running
timeout 180 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-backend-pg | grep -q "Up"; do sleep 5; done'
# Wait for frontend to be running
timeout 120 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done'
# Additional wait for services to be fully ready and listening on ports
sleep 30
# Check that backend is actually responding on the API endpoint
echo "Checking if backend service is ready..."
for i in {1..30}; do
if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service is ready!"
break
fi
echo "Waiting for backend service to be ready... ($i/30)"
sleep 5
done
# Final check
if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service failed to start properly. Checking logs..."
docker logs ruoyi-backend-pg-test
exit 1
fi
- name: Run tests
run: |
cd ruoyi-fastapi-test
pip install -r requirements.txt
playwright install
pytest -v
- name: Stop services
if: always()
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.pg.yml down
- name: Restore original Dockerfile.pg
if: always()
run: |
# Restore original Dockerfile.pg after test
mv ruoyi-fastapi-backend/Dockerfile.pg.bak ruoyi-fastapi-backend/Dockerfile.pg
-69
View File
@@ -1,69 +0,0 @@
name: Plugin Frontend Tests
on:
push:
branches: [master]
paths:
- 'ruoyi-fastapi-frontend/src/api/system/plugin.js'
- 'ruoyi-fastapi-frontend/src/components/PluginFrame/**'
- 'ruoyi-fastapi-frontend/src/views/system/plugin/**'
- 'ruoyi-fastapi-frontend/src/utils/**'
- 'ruoyi-fastapi-frontend/src/plugins/**'
- 'ruoyi-fastapi-frontend/src/router/**'
- 'ruoyi-fastapi-frontend/src/store/modules/permission.js'
- 'ruoyi-fastapi-frontend/src/store/modules/user.js'
- 'ruoyi-fastapi-frontend/src/permission.js'
- 'ruoyi-fastapi-frontend/plugins/**'
- 'ruoyi-fastapi-frontend/tests/plugins/**'
- 'ruoyi-fastapi-frontend/tests/run-tests.js'
- 'ruoyi-fastapi-frontend/package.json'
- 'ruoyi-fastapi-frontend/package-lock.json'
- 'ruoyi-fastapi-frontend/vite.config.js'
- 'ruoyi-fastapi-frontend/vite/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**'
- '.github/workflows/plugin-frontend.yml'
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-frontend/src/api/system/plugin.js'
- 'ruoyi-fastapi-frontend/src/components/PluginFrame/**'
- 'ruoyi-fastapi-frontend/src/views/system/plugin/**'
- 'ruoyi-fastapi-frontend/src/utils/**'
- 'ruoyi-fastapi-frontend/src/plugins/**'
- 'ruoyi-fastapi-frontend/src/router/**'
- 'ruoyi-fastapi-frontend/src/store/modules/permission.js'
- 'ruoyi-fastapi-frontend/src/store/modules/user.js'
- 'ruoyi-fastapi-frontend/src/permission.js'
- 'ruoyi-fastapi-frontend/plugins/**'
- 'ruoyi-fastapi-frontend/tests/plugins/**'
- 'ruoyi-fastapi-frontend/tests/run-tests.js'
- 'ruoyi-fastapi-frontend/package.json'
- 'ruoyi-fastapi-frontend/package-lock.json'
- 'ruoyi-fastapi-frontend/vite.config.js'
- 'ruoyi-fastapi-frontend/vite/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**'
- '.github/workflows/plugin-frontend.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
plugins:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ruoyi-fastapi-frontend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/package.json
- name: Install frontend dependencies
# This repository does not commit npm lockfiles, so npm ci is not available.
run: npm install --no-audit --no-fund --package-lock=false
- name: Verify plugin bridge, sessions and management views
run: npm run test:plugin
@@ -1,63 +0,0 @@
name: Plugin Network Smoke
on:
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/**'
- 'ruoyi-fastapi-backend/module_plugin/**'
- 'ruoyi-fastapi-backend/module_admin/service/login_service.py'
- 'ruoyi-fastapi-backend/common/**'
- 'ruoyi-fastapi-backend/config/**'
- 'ruoyi-fastapi-backend/middlewares/**'
- 'ruoyi-fastapi-backend/utils/**'
- 'ruoyi-fastapi-backend/tests/**'
- 'ruoyi-fastapi-backend/requirements.txt'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-frontend/**'
- '.github/workflows/plugin-network-smoke.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
browser:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ruoyi-fastapi-backend
env:
RUOYI_PLUGIN_NETWORK_SMOKE: '1'
# The fixture replaces account, DB and Redis access; no external service is used.
DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}'
JWT_SECRET_KEY: 'isolated-plugin-network-smoke-ci-only'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements.txt
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/package.json
- name: Install host and browser test dependencies
run: python -m pip install -r requirements.txt pytest pytest-asyncio aiosqlite playwright
- name: Install Chromium
run: python -m playwright install --with-deps chromium
- name: Install frontend dependencies
# This repository does not commit npm lockfiles, so npm ci is not available.
run: npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false
- name: Verify HTTPS proxy, iframe sessions and long connections
run: python -m pytest tests/plugins/integration/test_network_smoke.py -v --junitxml=target/plugin-network-smoke/results.xml
- name: Upload browser trace, screenshots and proxy log
if: always()
uses: actions/upload-artifact@v4
with:
name: plugin-network-smoke
path: ruoyi-fastapi-backend/target/plugin-network-smoke/
retention-days: 7
@@ -1,94 +0,0 @@
name: Plugin Release Integration
on:
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/core/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**'
- 'ruoyi-fastapi-frontend/src/utils/pluginBridge.js'
- 'ruoyi-fastapi-backend/config/**'
- 'ruoyi-fastapi-backend/cli/groups/plugin/**'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/**'
- 'ruoyi-fastapi-backend/module_plugin/**'
- 'ruoyi-fastapi-backend/server.py'
- 'ruoyi-fastapi-backend/app.py'
- 'ruoyi-fastapi-backend/sql/ruoyi-fastapi*.sql'
- 'ruoyi-fastapi-backend/scripts/plugin_release_integration.py'
- 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py'
- 'ruoyi-fastapi-backend/tests/plugins/**'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/**'
- 'ruoyi-fastapi-backend/tests/module_plugin/**'
- 'ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py'
- 'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-backend/requirements.txt'
- 'ruoyi-fastapi-backend/requirements-pg.txt'
- '.github/workflows/plugin-release-integration.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
defaults:
run:
working-directory: ruoyi-fastapi-backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ci-only-mysql
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: ci-only-postgres
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
redis:
image: redis:6.2
ports: ['6379:6379']
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s --health-timeout=5s --health-retries=12
env:
RUOYI_PLUGIN_IT_ALLOW: '1'
RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql
RUOYI_IT_PG_PASSWORD: ci-only-postgres
RUOYI_IT_REDIS_PASSWORD: ''
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- name: Install host dependencies
run: python -m pip install -r requirements.txt -r requirements-pg.txt
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Test and build task plugin bundle
run: |
npm --prefix plugins/examples/python/task_demo/web install --no-audit --no-fund --package-lock=false
npm --prefix plugins/examples/python/task_demo/web test
npm --prefix plugins/examples/python/task_demo/web run build
- name: Verify MySQL and Redis maintenance releases
run: python scripts/plugin_release_integration.py --engine mysql
- name: Verify PostgreSQL and Redis maintenance releases
run: python scripts/plugin_release_integration.py --engine postgresql
-33
View File
@@ -1,33 +0,0 @@
name: Ruff Check
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run linting
run: |
ruff check ruoyi-fastapi-backend
ruff check ruoyi-fastapi-test
- name: Run format check
run: |
ruff format ruoyi-fastapi-backend --check
ruff format ruoyi-fastapi-test --check
-106
View File
@@ -1,106 +0,0 @@
name: Timezone Integration
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
jobs:
databases:
runs-on: ubuntu-latest
env:
TZ: America/New_York
PYTHONUTF8: '1'
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: timezone_test
TZ: Asia/Shanghai
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -ptimezone_test"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: timezone_test
TZ: America/New_York
PGTZ: America/New_York
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- name: Install verification dependencies
working-directory: ruoyi-fastapi-backend
run: python -m pip install -r requirements.txt asyncpg psycopg2-binary pytest pytest-asyncio aiosqlite
- name: Create isolated source configurations
working-directory: ruoyi-fastapi-backend
run: |
mkdir -p .cache/timezone-ci
python - <<'PY'
import json
from pathlib import Path
for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]:
source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username,
db_password='timezone_test', db_database=database)
Path(f'.cache/timezone-ci/{dialect}.json').write_text(json.dumps(source))
PY
- name: Verify empty databases, drivers, generated CRUD and persistent schedulers
working-directory: ruoyi-fastapi-backend
run: |
python -m scripts.verify_timezone_database --config .cache/timezone-ci/mysql.json --report .cache/timezone-ci/mysql-report.json
python -m scripts.verify_timezone_database --config .cache/timezone-ci/postgresql.json --report .cache/timezone-ci/postgresql-report.json
- uses: actions/upload-artifact@v6
if: always()
with:
name: timezone-database-reports
path: ruoyi-fastapi-backend/.cache/timezone-ci/*-report.json
include-hidden-files: true
web-app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
# Lockfiles are ignored by this repository; cache by the committed manifest.
cache-dependency-path: ruoyi-fastapi-frontend/package.json
- uses: pnpm/action-setup@v5
with:
package_json_file: ruoyi-fastapi-app/package.json
- name: Install Web dependencies
working-directory: ruoyi-fastapi-frontend
run: npm install --no-package-lock
- name: Install App dependencies
working-directory: ruoyi-fastapi-app
run: pnpm install --no-frozen-lockfile
- name: Run time contracts in three host timezones
run: node ruoyi-fastapi-test/time-contract/run-host-timezones.mjs
- name: Build Web targets
working-directory: ruoyi-fastapi-frontend
run: |
npm run build:prod
npm run build:docker
- name: Build App targets
working-directory: ruoyi-fastapi-app
run: |
pnpm build:h5
pnpm build:mp-weixin
- name: Validate all Compose configurations
run: |
docker compose -f docker-compose.my.yml config --quiet
docker compose -f docker-compose.pg.yml config --quiet
docker compose -f ruoyi-fastapi-test/docker-compose.test.my.yml config --quiet
docker compose -f ruoyi-fastapi-test/docker-compose.test.pg.yml config --quiet