Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
pytest==9.1.1
PyYAML==6.0.3
ruff==0.16.1
ruff==0.17.1
8 changes: 6 additions & 2 deletions .github/scripts/ci_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

ROOT = Path(__file__).resolve().parents[2]
FRAMEWORKS = ('vue2', 'vue3', 'react')
PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13')
PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13', '3.14')
CATEGORIES = ('quality', 'backend', 'frontend', 'integration', 'e2e', 'native')
FRONTEND_FILE_PATH_PARTS = 4

Expand All @@ -34,6 +34,7 @@ def build_plan(paths: list[str], *, full: bool = False) -> dict[str, Any]: # no
'ruoyi-fastapi-backend/tests/conftest.py',
'ruoyi-fastapi-test/requirements.txt',
'.gitignore',
'.nvmrc',
)
for path in normalized
)
Expand Down Expand Up @@ -100,7 +101,10 @@ def build_plan(paths: list[str], *, full: bool = False) -> dict[str, Any]: # no
if target == 'web':
browsers.add(framework)
networks.add(framework)
if target == 'web' and (parts[-1].startswith('pluginBridge') or parts[-1] == 'package.json'):
if target == 'web' and (
parts[-1].startswith('pluginBridge')
or parts[-1] in ('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml')
):
backend = native = release = True
elif path.startswith('ruoyi-fastapi-frontend/'):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
Expand Down
82 changes: 74 additions & 8 deletions .github/scripts/mobile_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@
import time
from collections.abc import Awaitable, Callable
from functools import partial
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import Request, urlopen

from playwright.async_api import Route, async_playwright, expect
from playwright.async_api import Locator, Page, Route, async_playwright, expect

ROOT = Path(__file__).resolve().parents[2]
TOKEN = 'isolated-mobile-smoke-token'
LOGIN_BUTTON_MIN_WIDTH = 250
LOGIN_BUTTON_MIN_HEIGHT = 44
USER = {
'userId': 7,
'userName': 'ci-user',
Expand Down Expand Up @@ -96,12 +100,47 @@ async def api(route: Route) -> None:
return api


async def check_login_styles(framework: str, page: Page, login_button: Locator) -> dict[str, Any]:
"""验证移动端构建确实生成了布局、渐变和图标样式。"""
styles = await login_button.evaluate(
"""element => {
const style = getComputedStyle(element)
const rect = element.getBoundingClientRect()
return { width: rect.width, height: rect.height,
background: style.backgroundImage, radius: style.borderRadius }
}"""
)
assert styles['width'] >= LOGIN_BUTTON_MIN_WIDTH and styles['height'] >= LOGIN_BUTTON_MIN_HEIGHT, styles
assert 'linear-gradient' in styles['background'], styles
assert float(styles['radius'].removesuffix('px')) > 0, styles
if framework != 'react':
icon_mask = await page.locator('.i-mdi-account').first.evaluate(
'element => getComputedStyle(element).maskImage'
)
assert 'url(' in icon_mask, f'Tailwind 图标未生成:{icon_mask}'
if framework == 'vue2':
body = page.locator('uni-page-body')
header = page.locator('uni-page-head .uni-page-head')
await expect(body).to_have_css('display', 'block')
await expect(header).to_have_css('position', 'fixed')
layout = await body.evaluate(
"""element => ({
bodyHeight: element.getBoundingClientRect().height,
headerHeight: document.querySelector('uni-page-head .uni-page-head').getBoundingClientRect().height,
viewportHeight: window.innerHeight
})"""
)
assert round(layout['bodyHeight'] + layout['headerHeight']) >= layout['viewportHeight'], layout
styles['pageLayout'] = layout
return styles


async def run_smoke(framework: str, base_url: str, output: Path) -> None:
"""
使用真实 H5 构建验证登录、持久化身份和个人资料页。

:param framework: vue2、vue3 或 react
:param base_url: 仅用于本次测试的静态文件服务器
:param base_url: 本次测试的开发服务器或静态文件服务器
:param output: 浏览器报告目录
:return: None
"""
Expand All @@ -127,10 +166,13 @@ async def run_smoke(framework: str, base_url: str, output: Path) -> None:
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)
button = page.get_by_role('button') if framework == 'react' else page.locator('uni-button')
login_button = button.filter(has_text=re.compile(r'登\s*录'))
login_styles = await check_login_styles(framework, page, login_button)
await page.screenshot(path=str(output / 'login.png'))
await page.locator('input[type="text"]').fill('ci-user')
await page.locator('input[type="password"]').fill('ci-test-password')
button = page.get_by_role('button') if framework == 'react' else page.locator('uni-button')
await button.filter(has_text=re.compile(r'登\s*录')).click()
await login_button.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)
Expand All @@ -141,7 +183,11 @@ async def run_smoke(framework: str, base_url: str, output: Path) -> None:
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),
json.dumps(
{'framework': framework, 'ok': True, 'loginStyles': login_styles, 'requests': observed},
ensure_ascii=False,
indent=2,
),
encoding='utf-8',
)
print(f'{framework}: H5 login, persisted session and timezone profile PASS')
Expand All @@ -159,19 +205,39 @@ async def run_smoke(framework: str, base_url: str, output: Path) -> None:

def main() -> None:
"""
在独立临时端口启动 H5 静态服务,结束后关闭自身创建的资源。
验证现有开发服务器或临时 H5 静态服务,结束后关闭自身创建的资源。

:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('--framework', choices=('vue2', 'vue3', 'react'), required=True)
parser.add_argument('--base-url', help='验证已启动的 H5 开发服务器,不启动静态文件服务')
parser.add_argument('--output', type=Path, help='浏览器诊断文件的输出目录')
args = parser.parse_args()
output = args.output or ROOT / 'target/ci/mobile-smoke' / args.framework
if args.base_url and not args.output:
output /= 'dev'
output.mkdir(parents=True, exist_ok=True)
if args.base_url:
base_url = args.base_url.rstrip('/')
deadline = time.monotonic() + 180
while True:
try:
with urlopen(Request(base_url, headers={'Accept': 'text/html'}), timeout=3) as response:
if response.status == HTTPStatus.OK:
break
except OSError:
if time.monotonic() >= deadline:
raise TimeoutError(f'H5 开发服务器未就绪:{base_url}') from None
if time.monotonic() >= deadline:
raise TimeoutError(f'H5 开发服务器未就绪:{base_url}')
time.sleep(0.5)
asyncio.run(run_smoke(args.framework, base_url, output))
return
build_path = 'dist/h5' if args.framework == 'react' else 'dist/build/h5'
distribution = ROOT / 'ruoyi-fastapi-frontend' / args.framework / 'mobile' / build_path
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()
Expand Down
10 changes: 5 additions & 5 deletions .github/tests/test_ci_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ def test_frontend_ci_commands_exist_in_every_project(framework: str, target: str
package = json.loads((directory / 'package.json').read_text(encoding='utf-8'))
required = {'test:unit', 'test:contract'}
required.update({'build:prod', 'build:docker'} if target == 'web' else {'build:h5', 'build:mp-weixin'})
if framework == 'react':
assert package['packageManager'].startswith('pnpm@')
assert (directory / 'pnpm-workspace.yaml').is_file()
if target == 'mobile':
required.update({'check:platforms', 'build:rn:android', 'build:rn:ios'})
assert package['packageManager'] == 'pnpm@11.28.6'
assert (directory / 'pnpm-workspace.yaml').is_file()
assert (directory / 'pnpm-lock.yaml').is_file()
if framework == 'react' and target == 'mobile':
required.update({'check:platforms', 'build:rn:android', 'build:rn:ios'})
assert required <= package['scripts'].keys()


Expand Down
17 changes: 14 additions & 3 deletions .github/tests/test_ci_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
SPEC.loader.exec_module(ci)
FRONTEND_PROJECT_COUNT = 6
STANDARD_E2E_COMBINATION_COUNT = 6
FULL_E2E_COMBINATION_COUNT = 24
FULL_E2E_COMBINATION_COUNT = 30


def test_readme_change_only_runs_quality() -> None:
Expand Down Expand Up @@ -77,7 +77,8 @@ def test_shared_auth_and_test_helpers_keep_all_network_suites(path: str) -> None


@pytest.mark.parametrize(
'path', ['.github/workflows/ci.yml', '.github/scripts/ci_plan.py', 'ruoyi-fastapi-backend/requirements-test.txt']
'path',
['.github/workflows/ci.yml', '.github/scripts/ci_plan.py', 'ruoyi-fastapi-backend/requirements-test.txt', '.nvmrc'],
)
def test_ci_and_dependency_changes_select_every_category(path: str) -> None:
"""CI 及共享依赖变更覆盖全部任务分类。"""
Expand Down Expand Up @@ -136,6 +137,16 @@ def test_shared_time_contract_runs_all_frontends_and_database_checks() -> None:
assert plan['database']


@pytest.mark.parametrize('filename', ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
def test_web_dependency_changes_keep_plugin_release_verification(filename: str) -> None:
"""仅传递依赖或 pnpm override 变更也必须保留插件依赖及发布验收。"""
plan = ci.build_plan([f'ruoyi-fastapi-frontend/vue3/web/{filename}'])
assert plan['required']['backend']
assert plan['required']['native']
assert plan['release']
assert plan['network_frameworks'] == ['vue3']


def test_framework_move_keeps_both_sides_in_plan() -> None:
"""跨框架移动文件保留来源和目标两侧验证。"""
plan = ci.build_plan(
Expand All @@ -149,7 +160,7 @@ def test_full_run_preserves_python_database_and_framework_combinations() -> None
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'}
assert {row['python'] for row in plan['e2e_matrix']['include']} == {'3.10', '3.11', '3.12', '3.13', '3.14'}


@pytest.mark.parametrize('result', ['failure', 'cancelled', 'skipped', None])
Expand Down
72 changes: 72 additions & 0 deletions .github/tests/test_ci_python_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
from types import ModuleType

import pytest
import yaml

ROOT = Path(__file__).resolve().parents[2]


def load_module(name: str, path: Path) -> ModuleType:
"""按路径加载 CI 与 E2E 入口,以验证跨工程的版本契约。"""
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


ci = load_module('ci_plan_matrix', ROOT / '.github/scripts/ci_plan.py')
compose = load_module('prepare_e2e_compose_matrix', ROOT / 'ruoyi-fastapi-test/prepare_e2e_compose.py')


@pytest.mark.parametrize('workflow,job', [('ci-backend.yml', 'unit-contract'), ('ci-native.yml', 'runtime')])
def test_backend_and_native_cover_full_e2e_python_versions(workflow: str, job: str) -> None:
"""后端和原生运行时不能遗漏完整 E2E 矩阵中的 Python 版本。"""
config = yaml.safe_load((ROOT / '.github/workflows' / workflow).read_text(encoding='utf-8'))
assert config['jobs'][job]['strategy']['matrix']['python'] == list(ci.PYTHON_VERSIONS)


def test_release_covers_full_matrix_and_preserves_pr_baseline() -> None:
"""插件发布的完整矩阵跟随 E2E,PR 仍使用单版本基线。"""
config = yaml.safe_load((ROOT / '.github/workflows/ci-integration.yml').read_text(encoding='utf-8'))
expression = config['jobs']['release']['strategy']['matrix']['python']
versions = [json.loads(value) for value in re.findall(r"'(\[[^']+\])'", expression)]
assert versions == [list(ci.PYTHON_VERSIONS), ['3.12']]


@pytest.mark.parametrize('python_version', ci.PYTHON_VERSIONS)
@pytest.mark.parametrize('database,suffix', [('mysql', 'my'), ('postgresql', 'pg')])
def test_e2e_cli_accepts_matrix_and_passes_version_to_docker(
python_version: str, database: str, suffix: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""实际 CLI 解析每个矩阵版本,并将其传入两种数据库的 Docker 构建参数。"""
service = f'ruoyi-backend-{suffix}'
configuration = {'services': {service: {'build': {'context': 'backend'}}}}
result = subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps(configuration))
monkeypatch.setattr(compose.subprocess, 'run', lambda *args, **kwargs: result)
output = tmp_path / 'compose.json'
monkeypatch.setattr(
sys,
'argv',
[
'prepare_e2e_compose.py',
'--database',
database,
'--framework',
'vue3',
'--python-version',
python_version,
'--project',
'ruoyi-ci-python-matrix',
'--output',
str(output),
],
)
compose.main()
generated = json.loads(output.read_text(encoding='utf-8'))
assert generated['services'][service]['build']['args']['PYTHON_VERSION'] == python_version
8 changes: 5 additions & 3 deletions .github/workflows/ci-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
python: ['3.10', '3.11', '3.12', '3.13', '3.14']
defaults:
run:
working-directory: ruoyi-fastapi-backend
Expand All @@ -31,10 +31,12 @@ jobs:
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 MySQL, PostgreSQL and test dependencies
run: python -m pip install -r requirements.txt -r requirements-pg.txt -r requirements-test.txt
- name: Install AI plugin dependencies from its manifest
run: python ../.github/scripts/install_ai_test_dependencies.py
- name: Check dependency consistency
run: python -m pip check
- 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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
- name: Install test runner and Chromium
run: |
python -m pip install -r ruoyi-fastapi-test/requirements.txt
python -m pip check
python -m playwright install --with-deps chromium
- name: Verify reusable browser and isolated contexts
working-directory: ruoyi-fastapi-test
Expand Down
Loading
Loading