mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
* fix(ci): measure PR gates from the target branch's current tip The lint gate base, the ui lint and ui unit test scopes, and the spend logs guard compared the event's pull_request.base.sha with the head to find the merge base. GitHub can leave base.sha weeks behind the base branch, and when that old commit is an ancestor of a head that merged newer main, compare returns the old commit itself, so the strict gates blamed main's own code on the PR. Compare base.ref with the head instead, passed through env, so GitHub resolves the branch tip when the job runs. This is the same base make check uses locally. The ui unit test push guard now keys on BASE_REF, which is empty on push and workflow_dispatch. The regression test runs every workflow step that reads merge_base_commit against a fake gh backed by a temp repo with a stale base.sha, for main and a litellm_/ base with a slash. * fix(ci): check out the base branch's current guard code in the PR guards * fix(ci): read the PR's current base branch so re-runs survive a deleted base Every merge-base step now asks the pulls API for the PR's base branch instead of reading the event's base.ref, which a re-run keeps after a stacked base is merged and deleted. The guard checkouts go back to base.sha, which stays reachable. * test(ci): mark the step script path Final * fix(ci): keep the spend logs guard on main and run both guard scripts from the base branch tip * fix(ci): measure from the event's base branch unless it was deleted, and test what each step hands on A re-run after a retarget off main kept following the PR's current base, so lint, ui-unit and the cost map guard could pass without measuring against main. They now read the current base only when the event's base branch no longer exists. The tests run each merge base step whole and check the value its consumer gets * fix(ci): follow a renamed base branch to its new name before the compare --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
191 lines
6.8 KiB
Python
191 lines
6.8 KiB
Python
"""Regression tests for the UI unit-test scope decision.
|
|
|
|
`.github/workflows/test-unit.yml` narrows the dashboard's Vitest run to
|
|
`vitest related <changed files>` so a pull request only pays for the tests it can
|
|
affect. `related` resolves a file to the tests that import it, so a file no test
|
|
imports resolves to nothing, and with `--passWithNoTests` the job then goes green
|
|
without running a single test. `package.json`, `package-lock.json`, the Vitest and
|
|
TypeScript configs and `tests/setupTests.ts` are all such files, so a dependency
|
|
bump used to merge untested.
|
|
|
|
`.github/scripts/select_ui_test_scope.sh` is the decision function that closes
|
|
that hole: it prints `related` only when every changed file lives under `src/`,
|
|
and `full` otherwise. These tests lock both the decision and the workflow step
|
|
that consumes it, running the step's real shell against stubbed `gh`, `git` and
|
|
`npm` so a regression shows up as the wrong Vitest command.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SCOPE_SCRIPT = REPO_ROOT / ".github" / "scripts" / "select_ui_test_scope.sh"
|
|
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "test-unit.yml"
|
|
STEP_NAME = "Run UI unit tests (Vitest)"
|
|
EXPRESSION = re.compile(r"\$\{\{\s*(.+?)\s*\}\}")
|
|
PULL_REQUEST_EVENT: Final = MappingProxyType(
|
|
{"number": "1", "base.ref": "main", "base.sha": "basesha", "head.sha": "headsha"}
|
|
)
|
|
|
|
FULL_SUITE_ARGV = ["run", "test", "--", "--run", "--pool", "forks", "--maxWorkers=14"]
|
|
|
|
NON_SRC_FILES = [
|
|
"package.json",
|
|
"package-lock.json",
|
|
"vitest.config.ts",
|
|
"tsconfig.json",
|
|
"tests/setupTests.ts",
|
|
"next.config.mjs",
|
|
]
|
|
|
|
|
|
def scope(changed: list[str]) -> str:
|
|
result = subprocess.run(
|
|
["bash", str(SCOPE_SCRIPT)],
|
|
input="\n".join(changed),
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.mark.parametrize("changed_file", NON_SRC_FILES)
|
|
def test_a_file_no_test_imports_selects_the_full_suite(changed_file: str) -> None:
|
|
assert scope([changed_file]) == "full"
|
|
|
|
|
|
def test_src_only_changes_stay_on_related() -> None:
|
|
assert scope(["src/app/page.tsx", "src/lib/http/client.ts"]) == "related"
|
|
|
|
|
|
def test_one_non_src_file_pulls_a_src_only_set_up_to_full() -> None:
|
|
assert scope(["src/app/page.tsx", "package-lock.json"]) == "full"
|
|
|
|
|
|
def test_a_path_merely_prefixed_with_src_is_not_under_src() -> None:
|
|
assert scope(["srcipts/build.mjs"]) == "full"
|
|
|
|
|
|
def test_an_empty_change_set_fails_open_to_the_full_suite() -> None:
|
|
assert scope([]) == "full"
|
|
|
|
|
|
def _resolve(text: str, pull_request: Mapping[str, str] | None) -> str:
|
|
def value(match: re.Match[str]) -> str:
|
|
expression: Final = match.group(1)
|
|
if expression == "github.repository":
|
|
return "BerriAI/litellm"
|
|
if expression == "github.token":
|
|
return "token"
|
|
field: Final = expression.removeprefix("github.event.pull_request.")
|
|
assert field != expression, f"the step uses ${{{{ {expression} }}}}, which this harness does not resolve"
|
|
return "" if pull_request is None else pull_request[field]
|
|
|
|
return EXPRESSION.sub(value, text)
|
|
|
|
|
|
def _step_script_and_env(pull_request: Mapping[str, str] | None) -> tuple[str, dict[str, str]]:
|
|
workflow: Final = yaml.safe_load(WORKFLOW.read_text())
|
|
steps: Final = workflow["jobs"]["ui-unit"]["steps"]
|
|
step: Final = next(step for step in steps if step.get("name") == STEP_NAME)
|
|
env: Final = {name: _resolve(str(value), pull_request) for name, value in step.get("env", {}).items()}
|
|
return _resolve(step["run"], pull_request), env
|
|
|
|
|
|
def _stub(bin_dir: Path, name: str, body: str) -> None:
|
|
stub = bin_dir / name
|
|
stub.write_text(f"#!/usr/bin/env bash\n{body}\n")
|
|
stub.chmod(0o755)
|
|
|
|
|
|
def _run_step(
|
|
tmp_path: Path, changed: list[str], pull_request: Mapping[str, str] | None = PULL_REQUEST_EVENT
|
|
) -> tuple[int, str, list[list[str]]]:
|
|
"""Run the workflow step's real shell; return its status, stdout and every npm argv."""
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
changed_file = tmp_path / "changed.txt"
|
|
changed_file.write_text("".join(f"{name}\n" for name in changed))
|
|
npm_log = tmp_path / "npm.log"
|
|
|
|
_stub(bin_dir, "gh", 'echo "mergebasesha"')
|
|
_stub(bin_dir, "git", 'if [ "$1" = diff ]; then cat "$CHANGED_FILES"; fi')
|
|
_stub(bin_dir, "npm", 'printf "%s\\n" "$@" >>"$NPM_LOG"; printf "\\0" >>"$NPM_LOG"')
|
|
|
|
step_script, step_env = _step_script_and_env(pull_request)
|
|
script: Final = tmp_path / "step.sh"
|
|
script.write_text(step_script)
|
|
|
|
env: Final = {
|
|
**os.environ,
|
|
**step_env,
|
|
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
|
"GITHUB_WORKSPACE": str(REPO_ROOT),
|
|
"GITHUB_REF_NAME": "release_branch",
|
|
"CHANGED_FILES": str(changed_file),
|
|
"NPM_LOG": str(npm_log),
|
|
}
|
|
|
|
result = subprocess.run(
|
|
["bash", "--noprofile", "--norc", "-eo", "pipefail", str(script)],
|
|
cwd=tmp_path,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
raw = npm_log.read_text() if npm_log.exists() else ""
|
|
invocations = [call.splitlines() for call in raw.split("\0") if call]
|
|
return result.returncode, result.stdout + result.stderr, invocations
|
|
|
|
|
|
def _related_argv(changed: list[str]) -> list[str]:
|
|
return [
|
|
"run",
|
|
"test",
|
|
"--",
|
|
"related",
|
|
*changed,
|
|
"--run",
|
|
"--passWithNoTests",
|
|
"--pool",
|
|
"forks",
|
|
"--maxWorkers=14",
|
|
]
|
|
|
|
|
|
def test_step_runs_related_for_a_src_only_pull_request(tmp_path: Path) -> None:
|
|
changed = ["src/app/page.tsx", "src/lib/http/client.ts"]
|
|
returncode, output, invocations = _run_step(tmp_path, changed)
|
|
assert returncode == 0, output
|
|
assert invocations == [_related_argv(changed)]
|
|
|
|
|
|
@pytest.mark.parametrize("changed_file", NON_SRC_FILES)
|
|
def test_step_runs_the_full_suite_when_a_changed_file_is_outside_src(tmp_path: Path, changed_file: str) -> None:
|
|
returncode, output, invocations = _run_step(tmp_path, ["src/app/page.tsx", changed_file])
|
|
assert returncode == 0, output
|
|
assert invocations == [FULL_SUITE_ARGV]
|
|
assert "related" not in " ".join(invocations[0])
|
|
|
|
|
|
def test_step_runs_the_full_suite_on_a_push(tmp_path: Path) -> None:
|
|
returncode, output, invocations = _run_step(tmp_path, ["src/app/page.tsx"], pull_request=None)
|
|
assert returncode == 0, output
|
|
assert invocations == [FULL_SUITE_ARGV]
|
|
|
|
|
|
def test_step_runs_nothing_when_the_pull_request_touches_no_dashboard_file(tmp_path: Path) -> None:
|
|
returncode, output, invocations = _run_step(tmp_path, [])
|
|
assert returncode == 0, output
|
|
assert invocations == []
|