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
98 changes: 91 additions & 7 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
pull_request:
paths:
- ".github/workflows/python-tests.yml"
- ".github/workflows/sonarcloud.yml"
- "sonar-project.properties"
- "apps/**"
- "loopx/**"
- "scripts/**"
- "tests/**"
Expand All @@ -17,6 +20,9 @@ on:
- main
paths:
- ".github/workflows/python-tests.yml"
- ".github/workflows/sonarcloud.yml"
- "sonar-project.properties"
- "apps/**"
- "loopx/**"
- "scripts/**"
- "tests/**"
Expand All @@ -34,12 +40,9 @@ concurrency:
cancel-in-progress: true

jobs:
pytest:
checks:
runs-on: ubuntu-latest
# The parallel suite can take more than 15 minutes through coverage
# collation on the hosted runner. Keep the fail-safe bounded while leaving
# enough headroom for runner variance and post-test coverage reporting.
timeout-minutes: 30
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v7
Expand Down Expand Up @@ -85,12 +88,93 @@ jobs:
LOOPX_CLI_OUTPUT_BASE_REF: origin/${{ github.event.pull_request.base.ref || 'main' }}
run: python examples/control_plane/cli-output-budget-regression-smoke.py

- name: Run fast tests
test-shard:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
- uses: actions/setup-node@v6
with:
node-version: "22.6"
cache: npm
cache-dependency-path: package-lock.json
- name: Install test dependencies
run: |
python -m pip install --disable-pip-version-check -e ".[test]"
npm ci --ignore-scripts
- name: Run test shard
# Split the whole collection, not a hand-maintained list of directories.
# Without timing history least_duration alternates equal-weight tests.
# Each runner retains the measured two-worker pool.
run: >-
python -m pytest -q -n 2
--splits 2 --group ${{ matrix.shard }}
--splitting-algorithm least_duration
--durations=25 --durations-min=1
--cov=loopx
--cov-report=term
--cov-fail-under=19.6
- name: Upload shard coverage
uses: actions/upload-artifact@v7
with:
name: python-coverage-${{ matrix.shard }}
path: .coverage
include-hidden-files: true
if-no-files-found: error
retention-days: 3

pytest:
# Keep the required check name; a skipped/failed shard must not turn it green.
if: always()
needs: [checks, test-shard]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require every upstream check
env:
CHECKS_RESULT: ${{ needs.checks.result }}
SHARDS_RESULT: ${{ needs.test-shard.result }}
run: |
test "$CHECKS_RESULT" = success
test "$SHARDS_RESULT" = success
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
- run: python -m pip install --disable-pip-version-check -e ".[test]"
- uses: actions/download-artifact@v7
with:
pattern: python-coverage-*
path: coverage-shards
- name: Combine complete coverage and enforce the existing floor
run: |
test -s coverage-shards/python-coverage-1/.coverage
test -s coverage-shards/python-coverage-2/.coverage
python -m coverage combine coverage-shards/python-coverage-1/.coverage coverage-shards/python-coverage-2/.coverage
python -m coverage report --fail-under=19.6
python -m coverage xml -o coverage.xml
- uses: actions/upload-artifact@v7
with:
name: python-coverage-xml
path: coverage.xml
if-no-files-found: error
retention-days: 3

sonar:
needs: pytest
uses: ./.github/workflows/sonarcloud.yml
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

windows-powershell:
runs-on: windows-latest
Expand Down
54 changes: 8 additions & 46 deletions .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
@@ -1,44 +1,19 @@
name: SonarCloud

on:
pull_request:
paths:
- ".github/workflows/sonarcloud.yml"
- "sonar-project.properties"
- "loopx/**"
- "apps/**"
- "scripts/**"
- "tests/**"
- "package.json"
- "package-lock.json"
- "pyproject.toml"
- "tsconfig.control-plane.json"
push:
branches:
- main
paths:
- ".github/workflows/sonarcloud.yml"
- "sonar-project.properties"
- "loopx/**"
- "apps/**"
- "scripts/**"
- "tests/**"
- "package.json"
- "package-lock.json"
- "pyproject.toml"
- "tsconfig.control-plane.json"
workflow_call:
secrets:
SONAR_TOKEN:
required: false

permissions:
contents: read

concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true

jobs:
sonar:
name: SonarCloud analysis (non-blocking)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Detect whether the token is available
id: sonar-token
Expand All @@ -61,24 +36,11 @@ jobs:
with:
fetch-depth: 0

- name: Set up Python
- name: Download this run's combined coverage
if: steps.sonar-token.outputs.available == 'true'
uses: actions/setup-python@v6
uses: actions/download-artifact@v7
with:
python-version: "3.11"
cache: pip

- name: Install test dependencies
if: steps.sonar-token.outputs.available == 'true'
run: python -m pip install --disable-pip-version-check -e ".[test]"

- name: Run fast tests with coverage XML
if: steps.sonar-token.outputs.available == 'true'
run: >-
python -m pytest -q -n 2
--cov=loopx
--cov-report=term
--cov-report=xml:coverage.xml
name: python-coverage-xml

- name: SonarCloud scan
if: steps.sonar-token.outputs.available == 'true'
Expand Down
25 changes: 25 additions & 0 deletions docs/development/testing-and-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,31 @@ network latency, provider availability, or a two-hour matrix.
它刻意不包含真实模型调用和 full smoke catalog,因此普通迭代不依赖凭证、网络
时延、模型服务可用性或两小时级测试矩阵。

The Linux suite uses two hosted runners with two xdist workers each.
`pytest-split` partitions the complete collection using `least_duration`;
without a timing file, tests have equal weight and alternate between shards.
Lint, type checks, and the CLI budget run separately. The required `pytest`
check rejects failed/skipped shards and missing coverage artifacts, then uses
`coverage combine` to enforce the existing 19.6% floor on the union, not on
individual shards. Relative coverage paths make reports portable across runners.
The reusable Sonar workflow consumes that same run's XML and never reruns
pytest or reads cross-run artifacts. Missing Sonar tokens still skip analysis
successfully; test jobs receive no Sonar secret. The trigger is the union of
the former Python and Sonar paths, so app-only and Sonar-configuration changes
also run this lane, including on forks without a token.

Linux 全套测试分到两台 hosted runner,每台保留两个 xdist worker。`pytest-split`
按完整 collection 分片;没有历史耗时时,等权测试交替分配。lint、类型检查和 CLI
预算独立执行。必需的 `pytest` 汇总检查会拒绝失败/跳过的分片和缺失的 coverage,
合并后再执行原有 19.6% 门槛;不要求单个分片达到全套覆盖率。coverage 使用相对路径,
Sonar 只复用同一次 run 的 XML,不重复测试、不跨 run 取产物。缺少 token 仍成功跳过
Sonar,测试 job 不接收 Sonar secret。触发范围取原有两套 workflow 的并集,因此仅改
前端或 Sonar 配置也走此通道,包括没有 token 的 fork。

Reproduce one shard locally with `python -m pytest -q -n 2 --splits 2 --group 1
--splitting-algorithm least_duration --cov=loopx`. Omit the split arguments to
run the complete suite locally. 全量本地测试仍省略分片参数即可。

## Smokes And Canary / Smoke 与 Canary

A durable smoke should protect shipped behavior, a reusable contract, a
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ test = [
"pytest>=8,<9",
"pytest-cov>=5,<7",
"pytest-xdist>=3,<4",
"pytest-split>=0.10,<0.11",
"ruff>=0.12,<0.16",
"mypy>=1.18,<2",
]
Expand Down Expand Up @@ -118,6 +119,10 @@ include = ["loopx*"]
[tool.pytest.ini_options]
norecursedirs = ["deprecate"]

[tool.coverage.run]
source = ["loopx"]
relative_files = true

[tool.mypy]
python_version = "3.11"
strict = true
Expand Down
9 changes: 2 additions & 7 deletions tests/canary/test_maintainability_ratchet.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,12 @@ def test_current_repository_debt_is_reviewed_without_line_count_pins() -> None:
assert set(report["category_counts"]) == {"compatibility_facade"}
assert report["category_counts"].get("oversized_decision_function", 0) == 0
assert report["reviewed_exception_count"] == report["finding_count"]


def test_current_module_metric_budget_keeps_existing_modules_grandfathered() -> None:
report = build_control_plane_maintainability_report(REPOSITORY_ROOT)

assert report["ok"] is True, render_control_plane_maintainability_report(report)
# One scan of the immutable checkout owns both debt and metric assertions.
# Mutated temporary repositories below must still be evaluated afresh.
assert report["policy"]["module_line_limit"] == 1500
assert report["policy"]["module_any_limit"] == 300
assert report["policy"]["module_dict_any_limit"] == 300
assert report["category_counts"].get("module_metric_budget", 0) == 0
assert report["unreviewed_count"] == 0


def test_module_metric_ratchet_detects_new_oversized_module(tmp_path: Path) -> None:
Expand Down
10 changes: 0 additions & 10 deletions tests/control_plane/test_m6_quality_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from pathlib import Path

from loopx.canary.maintainability_ratchet import (
build_control_plane_maintainability_report,
module_metric_baseline,
module_metrics,
)
Expand Down Expand Up @@ -32,15 +31,6 @@ def test_m6_rfc_module_budgets_are_enforced_by_the_ratchet_baseline() -> None:
assert actual <= ceiling, relative_path


def test_m6_maintainability_ratchet_has_no_unreviewed_debt() -> None:
report = build_control_plane_maintainability_report(REPOSITORY_ROOT)

assert report["ok"] is True
assert report["unreviewed_count"] == 0
assert report["stale_exception_count"] == 0
assert report["category_counts"].get("module_metric_budget", 0) == 0


def test_quota_turn_envelope_consumes_effect_turn_at_runtime() -> None:
payload = {
"ok": True,
Expand Down
6 changes: 3 additions & 3 deletions tests/test_external_scheduler_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ def test_default_invocation_persists_backoff_state(

def test_unchanged_limit_runs_final_quota_probe_before_stop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
root = tmp_path / "final-probe"
fake_cli = root / "fake-loopx"
Expand All @@ -159,16 +158,17 @@ def test_unchanged_limit_runs_final_quota_probe_before_stop(
"counter.write_text(str(count + 1), encoding='utf-8')\n"
f"print({payload!r})\n",
)
monkeypatch.setattr(worker.time, "sleep", lambda _seconds: None)
requested_sleeps: list[float] = []
args = _args(
fake_cli=fake_cli,
state_file=root / "state.json",
quota_timeout_seconds=1.0,
)
args.once = False

assert worker.run_worker(args) == 0
assert worker.run_worker(args, sleep=requested_sleeps.append) == 0
assert counter.read_text(encoding="utf-8") == "3"
assert requested_sleeps == [60]


def test_quota_probe_timeout_enters_tick_error(tmp_path: Path) -> None:
Expand Down
Loading