diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da2b5df..96d0c62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,29 +1,9 @@ name: CI on: - push: - branches: [main] - # 在触发前过滤不影响当前构建的文件;与 tools/ci-changes.py 保持一致。 - paths: - - '**' - - '!*.md' - - '!LICENSE' - - '!.gitignore' - - '!docs/**' - - '!.github/ISSUE_TEMPLATE/**' - - '!.github/release-notes.md' - - '!.github/PULL_REQUEST_TEMPLATE.md' - - '!postman/mocks/**' - - '!postman/simulations/**' - - '!src-tauri/icons-v2/android/**' - - '!src-tauri/icons-v2/ios/**' - - 'CHANGELOG.md' - - 'docs/faq.md' - - 'docs/laboratory.md' - - 'docs/request-policy.md' pull_request: branches: [main] - # 保留必需检查结果,避免纯文档 PR 因缺少状态而无法合并。 + workflow_dispatch: permissions: contents: read @@ -37,21 +17,24 @@ jobs: name: Changed paths runs-on: ubuntu-latest outputs: - build: ${{ steps.filter.outputs.build }} + frontend: ${{ steps.filter.outputs.frontend }} + rust: ${{ steps.filter.outputs.rust }} + native: ${{ steps.filter.outputs.native }} + ci: ${{ steps.filter.outputs.ci }} steps: - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 - name: 核验路径过滤规则 - run: python3 -m unittest discover -s tools -p 'test_ci_changes.py' - - name: 判断是否需要构建 + run: python3 -B -m unittest discover -s tools -p 'test_ci*.py' + - name: 选择相关检查 id: filter run: python3 tools/ci-changes.py rust: name: Rust (windows-latest) needs: changes - if: needs.changes.outputs.build == 'true' + if: needs.changes.outputs.rust == 'true' runs-on: windows-latest steps: - uses: actions/checkout@v7.0.1 @@ -67,22 +50,11 @@ jobs: env: CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUNNER: powershell -NoProfile -ExecutionPolicy Bypass -File ${{ github.workspace }}/tools/run-windows-test.ps1 run: cargo test --locked -p mpider-core -p article-md -p mpider-tauri - - uses: actions/setup-python@v7.1.0 - if: runner.os == 'Windows' - with: - python-version: "3.12" - - name: Windows 原生协议离线测试 - shell: pwsh - if: runner.os == 'Windows' - run: | - $PSNativeCommandUseErrorActionPreference = $true - python -m pip install -r native/requirements.txt - python -B -m unittest discover -s native -p 'test_*.py' frontend: name: Frontend needs: changes - if: needs.changes.outputs.build == 'true' + if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 @@ -96,12 +68,56 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm exec tsc --noEmit -p frontend/tsconfig.json - run: pnpm build + - name: 界面契约与更新发行回归 + run: pnpm test:frontend + + native: + name: Native (${{ matrix.os }}) + needs: changes + if: needs.changes.outputs.native == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7.0.1 - uses: actions/setup-python@v7.1.0 with: python-version: "3.12" + - uses: pnpm/action-setup@v6.1.0 + with: + version: 9 + - uses: actions/setup-node@v7.1.0 + with: + node-version: 24 + - name: 安装原生测试依赖 + run: python -m pip install -r native/requirements.txt - name: 原生桥协议离线测试 - run: | - python -m pip install -r native/requirements.txt - python -m unittest discover -s native -p 'test_*.py' - - name: 界面契约与原生桥回归 - run: pnpm test + run: python -B -m unittest discover -s native -p 'test_*.py' + - name: 原生观察与列表过滤回归 + run: pnpm test:native + + ci: + name: CI configuration + needs: changes + if: needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - name: 路径判断与汇总检查回归 + run: python3 -B -m unittest discover -s tools -p 'test_ci*.py' + - name: 核验 workflow 语法与任务依赖 + uses: docker://rhysd/actionlint:1.7.12 + + result: + name: CI + if: always() + needs: [changes, frontend, rust, native, ci] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - name: 确认所需检查均已通过 + env: + CI_NEEDS: ${{ toJSON(needs) }} + run: python3 -B tools/check-ci-results.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd785eb..4d9bdfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,22 @@ pnpm test 按改动范围补充有意义的回归。说明实际运行的平台、命令与结果,区分离线测试、桌面启动、真实请求和长期观察;没有验证的部分明确写出。 +日常 CI 仅由面向 main 的 PR 或手动操作触发,合并后的普通 main 推送不重复运行。PR 按路径选择任务: + +| 改动范围 | 执行的检查 | +| --- | --- | +| 公开说明、截图、协作模板、`.gitignore`、本地 Postman 模拟、未使用的移动端图标 | 轻量路径判断与汇总,不运行应用构建 | +| 前端源码、广告默认配置、编入界面的 Markdown(含 `CHANGELOG.md`) | 前端类型、构建及界面回归 | +| Rust 实现、Windows 图标、Rust 测试样本 | Windows Rust 格式、Clippy 与测试 | +| 原生工作进程及指纹 | Rust 检查,以及 Linux/Windows Python 与原生观察脚本回归 | +| 前后端共享类型、IPC 或 Rust 原生协议 | 联动前端、Rust 与原生桥检查 | +| CI 路径判断、汇总脚本及其测试 | 路径与汇总回归、workflow 静态检查 | +| workflow 自身、共享依赖或未知构建输入 | 保守运行全部检查,验证配置实际可用 | + +混合改动取相关检查的并集。`pnpm test:frontend` 与 `pnpm test:native` 可分别运行 Node 回归;`pnpm test` 仍执行两组完整回归。手动运行 CI 会执行全部任务;版本标签的完整发行验证保持独立。 + +合并保护应要求固定的 `CI` 汇总检查,并要求 PR 基于最新 main 完成验证。汇总检查始终执行:所选任务失败、取消、意外跳过或路径判断失败都会阻止通过;无关任务可以跳过。这样纯文档 PR 不会因缺少构建状态而无法合并。 + ## 代码与数据约定 - 文档、注释、日志和 UI 文案使用中文;模块顶部说明职责。 diff --git a/README.md b/README.md index 5d1607c..63bfc12 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ cargo test --locked -p mpider-core -p article-md -p mpider-tauri pnpm tauri dev ``` -Windows 测试 runner、Python 离线测试和完整客户端构建见[Windows 构建说明](docs/windows-standalone.md)。CI 按改动路径检查前端与 Windows Rust:main 上仅修改公开资料、协作模板、`.gitignore`、本地 Postman 模拟或未使用的 Android/iOS 图标时不触发 CI,PR 保留路径判断以满足必需检查、跳过构建;编入界面的更新记录、FAQ、实验室与请求策略文档仍触发检查。客户端发行仅面向 Windows x64,标签必须与项目版本一致,完成构建、离线测试、便携包解压自检与签名清单生成后创建 Release 草稿。 +Windows 测试 runner、Python 离线测试和完整客户端构建见[Windows 构建说明](docs/windows-standalone.md)。日常 CI 在 PR 中按改动路径选择前端、Windows Rust、原生桥或 CI 配置检查,合并到 main 后不重复执行;纯公开资料只运行轻量路径判断与汇总,编入界面的更新记录、FAQ、实验室与请求策略文档仅触发前端检查。共享契约改动联动验证,手动触发可运行全部检查,详细规则见[贡献指南](CONTRIBUTING.md)。客户端发行仅面向 Windows x64,标签必须与项目版本一致,完成构建、离线测试、便携包解压自检与签名清单生成后创建 Release 草稿。 [贡献指南](CONTRIBUTING.md) · [实现原理](docs/how-it-works.md) · [更新记录](CHANGELOG.md) · [第三方声明](THIRD-PARTY-NOTICES.md) diff --git a/package.json b/package.json index c164e4b..bbd6bcf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "build": "vite build", "preview": "vite preview", "tauri": "tauri", - "test": "node --test tools/test_native_bridge_filter.mjs tools/test_session_agent.mjs tools/test_cursor_lab.ts tools/test_home_sync.ts tools/test_pagination.ts tools/test_relative_time.ts tools/test_advertisements.ts tools/test_action_visibility.ts tools/test_release_updater.mjs" + "test": "pnpm test:frontend && pnpm test:native", + "test:frontend": "node --test tools/test_cursor_lab.ts tools/test_home_sync.ts tools/test_pagination.ts tools/test_relative_time.ts tools/test_advertisements.ts tools/test_action_visibility.ts tools/test_release_updater.mjs", + "test:native": "node --test tools/test_native_bridge_filter.mjs tools/test_session_agent.mjs" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", diff --git a/tools/check-ci-results.py b/tools/check-ci-results.py new file mode 100644 index 0000000..ba2186c --- /dev/null +++ b/tools/check-ci-results.py @@ -0,0 +1,30 @@ +"""汇总 CI:应运行的任务必须成功,跳过、失败和取消不能冒充成功。""" + +import json +import os +import sys + + +def result_errors(needs): + changes = needs.get("changes", {}) + if changes.get("result") != "success": + return ["路径判断未成功,不能确认检查范围"] + errors = [] + for name in ("frontend", "rust", "native", "ci"): + enabled = changes.get("outputs", {}).get(name) + result = needs.get(name, {}).get("result") + if enabled not in ("true", "false"): + errors.append(f"{name}: 缺少有效的路径判断结果") + elif enabled == "true" and result != "success": + errors.append(f"{name}: 应执行的检查未通过({result})") + elif enabled == "false" and result not in ("success", "skipped"): + errors.append(f"{name}: 检查异常({result})") + return errors + + +if __name__ == "__main__": + errors = result_errors(json.loads(os.environ["CI_NEEDS"])) + if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) + print("所需检查均已通过,无关任务已跳过") diff --git a/tools/ci-changes.py b/tools/ci-changes.py index bc0dc41..feecb50 100644 --- a/tools/ci-changes.py +++ b/tools/ci-changes.py @@ -1,10 +1,12 @@ -"""仅让影响应用或构建的改动运行完整 CI;与 workflow 的 push 路径过滤同步。""" +"""按 PR 改动选择检查;共享契约联动验证,未知输入保守运行全部检查。""" import json import os from pathlib import Path import subprocess +CHECKS = ("frontend", "rust", "native", "ci") + EMBEDDED_GUIDES = { "CHANGELOG.md", "docs/faq.md", @@ -28,20 +30,59 @@ "src-tauri/icons-v2/ios/", ) +CI_FILES = { + "tools/ci-changes.py", "tools/test_ci_changes.py", + "tools/check-ci-results.py", "tools/test_ci_results.py", +} + +SHARED_CONTRACT_FILES = { + "frontend/src/lib/tauri.ts", "frontend/src/lib/workbench.ts", + "frontend/src/views/types.ts", "crates/core/src/model.rs", + "crates/core/src/workbench/mod.rs", "crates/core/src/store/workbench.rs", + "crates/core/src/store/native.rs", +} + +NATIVE_TESTS = {"tools/test_native_bridge_filter.mjs", "tools/test_session_agent.mjs"} + +FRONTEND_FILES = { + "vite.config.ts", "tailwind.config.cjs", "config/advertisements.json", + "tools/release-updater.mjs", "tools/test_release_updater.mjs", +} + -def requires_build(paths): +def checks_for_paths(paths): + selected = set() for path in paths: if path in EMBEDDED_GUIDES: - return True - if path in NON_BUILD_FILES or path.startswith(NON_BUILD_PREFIXES): + selected.add("frontend") + elif path in NON_BUILD_FILES or path.startswith(NON_BUILD_PREFIXES): continue - if "/" not in path and (path.endswith(".md") or path == "LICENSE"): + elif "/" not in path and (path.endswith(".md") or path == "LICENSE"): continue - return True - return False + elif path in CI_FILES: + selected.add("ci") + elif path in SHARED_CONTRACT_FILES or path.startswith(("src-tauri/src/", "crates/core/src/native/")): + selected.update(("frontend", "rust", "native")) + elif path in NATIVE_TESTS or (path.startswith("native/") and Path(path).name.startswith("test_")): + selected.add("native") + elif path.startswith("native/") or path == "tools/native_bridge_probe.js": + # 工作进程及指纹被 Rust include_str! 编入,需验证两边。 + selected.update(("rust", "native")) + elif path in {"Cargo.toml", "src-tauri/tauri.conf.json"}: + # 前端回归同时核对三处项目版本。 + selected.update(("frontend", "rust")) + elif path.startswith(("crates/", "src-tauri/")) or path in {"Cargo.lock", "tools/run-windows-test.ps1"}: + selected.add("rust") + elif (path.startswith("frontend/") or path in FRONTEND_FILES + or (path.startswith("tools/test_") and path.endswith((".ts", ".mjs")))): + selected.add("frontend") + else: + # workflow 自身、依赖清单、共享构建配置和未知文件实际运行检查。 + selected.update(CHECKS) + return {check: check in selected for check in CHECKS} -def changed_paths(event): +def changed_paths(event, *, cwd=None): if "pull_request" in event: pr = event["pull_request"] revision = pr["base"]["sha"] + "..." + pr["head"]["sha"] @@ -52,20 +93,24 @@ def changed_paths(event): command = ["git", "ls-files", "-z"] else: command = ["git", "diff", "--name-only", "--no-renames", "-z", before, event["after"]] - result = subprocess.check_output(command) + result = subprocess.check_output(command, cwd=cwd) return [os.fsdecode(path) for path in result.split(b"\0") if path] -if __name__ == "__main__": - event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) +def checks_for_event(event, event_name): + if event_name == "workflow_dispatch": + return dict.fromkeys(CHECKS, True) try: - paths = changed_paths(event) - build = requires_build(paths) + return checks_for_paths(changed_paths(event)) except subprocess.CalledProcessError: - # 强制更新等情况下旧对象可能不在本地,保守执行检查。 - paths = [] - build = True - value = str(build).lower() + # 旧对象缺失或 Git 比较失败时保守执行,禁止当成无改动跳过。 + return dict.fromkeys(CHECKS, True) + + +if __name__ == "__main__": + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + checks = checks_for_event(event, os.environ["GITHUB_EVENT_NAME"]) with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write("build=" + value + "\n") - print(json.dumps({"build": build, "changed_files": len(paths)})) + for name, enabled in checks.items(): + output.write(f"{name}={str(enabled).lower()}\n") + print(json.dumps(checks)) diff --git a/tools/test_ci_changes.py b/tools/test_ci_changes.py index 5bbd311..78d7442 100644 --- a/tools/test_ci_changes.py +++ b/tools/test_ci_changes.py @@ -1,7 +1,11 @@ import importlib.util +import os from pathlib import Path import re +import subprocess +import tempfile import unittest +from unittest.mock import patch spec = importlib.util.spec_from_file_location("ci_changes", Path(__file__).with_name("ci-changes.py")) ci_changes = importlib.util.module_from_spec(spec) @@ -9,53 +13,131 @@ class CiChangesTests(unittest.TestCase): - def test_docs_and_screenshots_skip_build(self): - self.assertFalse(ci_changes.requires_build([ - "README.md", "CONTRIBUTING.md", "docs/how-it-works.md", + def assert_checks(self, paths, *expected): + result = ci_changes.checks_for_paths(paths) + self.assertEqual(set(result), set(ci_changes.CHECKS)) + self.assertEqual({name for name, enabled in result.items() if enabled}, set(expected)) + + def test_public_material_and_unused_assets_skip_application_checks(self): + self.assert_checks([ + "README.md", "CONTRIBUTING.md", "LICENSE", "docs/how-it-works.md", "docs/screenshots/workbench-sources.png", ".github/release-notes.md", - ])) + ".gitignore", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/ISSUE_TEMPLATE/bug_report.yml", + "postman/mocks/updater-release/default.js", + "postman/simulations/updater-download.sim.yaml", + "src-tauri/icons-v2/android/mipmap-hdpi/ic_launcher.png", + "src-tauri/icons-v2/ios/AppIcon-20x20@1x.png", + ]) - def test_embedded_guides_are_application_inputs(self): - for path in ci_changes.EMBEDDED_GUIDES: + def test_frontend_inputs_do_not_compile_rust(self): + for path in ["CHANGELOG.md", "docs/faq.md", "docs/laboratory.md", "docs/request-policy.md", + "frontend/src/index.css", "frontend/src/components/Sidebar.tsx", + "frontend/src/assets/app-icon.png", "vite.config.ts", "tailwind.config.cjs", + "config/advertisements.json", "tools/test_home_sync.ts", "tools/release-updater.mjs"]: with self.subTest(path=path): - self.assertTrue(ci_changes.requires_build([path])) + self.assert_checks([path], "frontend") - def test_markdown_imported_by_frontend_requires_build(self): + def test_markdown_imported_by_frontend_is_checked(self): root = Path(__file__).resolve().parent.parent imports = [] for source in (root / "frontend/src").rglob("*.tsx"): - for relative in re.findall(r'from\s+["\x27]([^"\x27]+\.md)\?raw["\x27]', source.read_text()): + for relative in re.findall(r'''from\s+["']([^"']+\.md)\?raw["']''', source.read_text()): path = (source.parent / relative).resolve().relative_to(root).as_posix() imports.append(path) with self.subTest(path=path): - self.assertTrue(ci_changes.requires_build([path])) + self.assert_checks([path], "frontend") self.assertIn("CHANGELOG.md", imports) - def test_repository_metadata_local_simulations_and_mobile_icons_skip_build(self): - paths = [ - ".gitignore", ".github/PULL_REQUEST_TEMPLATE.md", - ".github/ISSUE_TEMPLATE/bug_report.yml", - "postman/mocks/updater-release/config.yaml", - "postman/mocks/updater-release/default.js", - "postman/simulations/updater-download.sim.yaml", - "src-tauri/icons-v2/android/mipmap-hdpi/ic_launcher.png", - "src-tauri/icons-v2/ios/AppIcon-20x20@1x.png", - ] - for path in paths: + def test_rust_inputs_do_not_build_frontend(self): + for path in ["crates/article-md/src/text.rs", "crates/core/src/ratelimit.rs", "Cargo.lock", + "src-tauri/Cargo.toml", "src-tauri/icons-v2/icon.ico", + "src-tauri/fixtures/updater/package.bin", "tools/run-windows-test.ps1"]: with self.subTest(path=path): - self.assertFalse(ci_changes.requires_build([path])) - self.assertTrue(ci_changes.requires_build([path, "frontend/src/App.tsx"])) - self.assertFalse(ci_changes.requires_build(paths)) - - def test_code_build_config_and_mixed_changes_run(self): - for path in ["frontend/src/App.tsx", "src-tauri/src/lib.rs", "native/bridge.py", - "Cargo.lock", "pnpm-lock.yaml", "tools/package-windows-release.py", - ".github/workflows/ci.yml", ".github/workflows/release.yml", - ".gitattributes", "config/advertisements.json", - "src-tauri/icons-v2/icon.ico", "src-tauri/fixtures/updater/package.bin", - "new-build-config.json"]: + self.assert_checks([path], "rust") + + def test_native_workers_are_checked_on_both_sides(self): + for path in ["native/session_protocol.py", "native/session_agent.js", + "native/profiles/4.1.15.56.json", "tools/native_bridge_probe.js"]: with self.subTest(path=path): - self.assertTrue(ci_changes.requires_build(["README.md", path])) + self.assert_checks([path], "rust", "native") + root = Path(__file__).resolve().parent.parent + source = root / "crates/core/src/native/runtime.rs" + for relative in re.findall(r'include_str!\("([^"]+)"\)', source.read_text()): + path = (source.parent / relative).resolve().relative_to(root).as_posix() + with self.subTest(embedded=path): + self.assert_checks([path], "rust", "native") + + def test_native_test_only_changes_do_not_compile_application(self): + for path in ["native/test_session_protocol.py", "tools/test_session_agent.mjs", + "tools/test_native_bridge_filter.mjs"]: + with self.subTest(path=path): + self.assert_checks([path], "native") + + def test_shared_contracts_link_relevant_checks(self): + for path in ["frontend/src/lib/tauri.ts", "frontend/src/lib/workbench.ts", + "src-tauri/src/lib.rs", "src-tauri/src/workbench.rs", + "crates/core/src/native/protocol.rs", "crates/core/src/model.rs"]: + with self.subTest(path=path): + self.assert_checks([path], "frontend", "rust", "native") + for path in ["Cargo.toml", "src-tauri/tauri.conf.json"]: + with self.subTest(version_input=path): + self.assert_checks([path], "frontend", "rust") + + def test_filter_and_summary_script_changes_only_validate_ci(self): + for path in ["tools/ci-changes.py", "tools/test_ci_changes.py", + "tools/check-ci-results.py", "tools/test_ci_results.py"]: + with self.subTest(path=path): + self.assert_checks([path], "ci") + + def test_workflows_dependencies_and_unknown_inputs_run_all_checks(self): + for path in [".github/workflows/ci.yml", ".github/workflows/release.yml", + "package.json", "pnpm-lock.yaml", ".gitattributes", + "tools/package-windows-release.py", "new-build-config.json"]: + with self.subTest(path=path): + self.assert_checks([path], *ci_changes.CHECKS) + + def test_mixed_changes_take_union(self): + self.assert_checks(["README.md", "frontend/src/index.css"], "frontend") + self.assert_checks(["CHANGELOG.md", "crates/article-md/src/text.rs"], "frontend", "rust") + self.assert_checks(["tools/ci-changes.py", "native/session_worker.py"], "ci", "native", "rust") + self.assert_checks([]) + + def test_manual_runs_and_diff_errors_do_not_skip_checks(self): + expected = dict.fromkeys(ci_changes.CHECKS, True) + self.assertEqual(ci_changes.checks_for_event({}, "workflow_dispatch"), expected) + with patch.object(ci_changes, "changed_paths", side_effect=subprocess.CalledProcessError(1, "git")): + self.assertEqual(ci_changes.checks_for_event({}, "pull_request"), expected) + + def test_real_pr_diff_includes_all_commits_deletions_and_renames(self): + with tempfile.TemporaryDirectory() as directory: + env = {**os.environ, "GIT_AUTHOR_NAME": "CI Fixture", "GIT_COMMITTER_NAME": "CI Fixture", + "GIT_AUTHOR_EMAIL": "ci@example.invalid", "GIT_COMMITTER_EMAIL": "ci@example.invalid"} + + def git(*args): + return subprocess.check_output(["git", "-c", "commit.gpgsign=false", *args], + cwd=directory, env=env, text=True).strip() + + def commit(): + git("add", "-A") + git("commit", "-qm", "fixture") + return git("rev-parse", "HEAD") + + root = Path(directory) + git("init", "-q") + (root / "native").mkdir() + (root / "native/worker.py").write_text("fixture\n") + (root / "README.md").write_text("before\n") + base = commit() + (root / "README.md").write_text("after\n") + commit() + (root / "docs").mkdir() + (root / "native/worker.py").rename(root / "docs/worker-example.py") + head = commit() + event = {"pull_request": {"base": {"sha": base}, "head": {"sha": head}}} + paths = ci_changes.changed_paths(event, cwd=directory) + self.assertEqual(set(paths), {"README.md", "native/worker.py", "docs/worker-example.py"}) + self.assert_checks(paths, "rust", "native") if __name__ == "__main__": diff --git a/tools/test_ci_results.py b/tools/test_ci_results.py new file mode 100644 index 0000000..71268a2 --- /dev/null +++ b/tools/test_ci_results.py @@ -0,0 +1,57 @@ +import copy +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location("ci_results", Path(__file__).with_name("check-ci-results.py")) +ci_results = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ci_results) + + +class CiResultsTests(unittest.TestCase): + def needs(self, *selected): + jobs = ("frontend", "rust", "native", "ci") + return { + "changes": {"result": "success", "outputs": {job: str(job in selected).lower() for job in jobs}}, + **{job: {"result": "success" if job in selected else "skipped"} for job in jobs}, + } + + def test_docs_can_skip_application_jobs(self): + self.assertEqual(ci_results.result_errors(self.needs()), []) + + def test_selected_jobs_must_succeed(self): + for selected in [("frontend",), ("rust", "native"), ("frontend", "rust", "native", "ci")]: + self.assertEqual(ci_results.result_errors(self.needs(*selected)), []) + for job in selected: + for result in ("failure", "cancelled", "skipped", None): + needs = self.needs(*selected) + needs[job]["result"] = result + with self.subTest(job=job, result=result): + self.assertTrue(ci_results.result_errors(needs)) + + def test_failed_path_detection_cannot_pass_with_skipped_dependents(self): + for result in ("failure", "cancelled", "skipped", None): + needs = self.needs() + needs["changes"]["result"] = result + self.assertTrue(ci_results.result_errors(needs)) + + def test_missing_or_invalid_outputs_cannot_silently_skip_checks(self): + valid = self.needs() + for job in ("frontend", "rust", "native", "ci"): + for value in (None, "", "TRUE", True): + needs = copy.deepcopy(valid) + needs["changes"]["outputs"][job] = value + self.assertTrue(ci_results.result_errors(needs)) + + def test_unselected_failures_and_missing_jobs_are_rejected(self): + for result in ("failure", "cancelled", None): + needs = self.needs() + needs["rust"]["result"] = result + self.assertTrue(ci_results.result_errors(needs)) + needs = self.needs() + del needs["native"] + self.assertTrue(ci_results.result_errors(needs)) + + +if __name__ == "__main__": + unittest.main()