mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(e2e): make coverage collection independent of the runner
The coverage number is documented as static, a property of the source tree, but
the same tree reported 312, 314, or 315 covered cells depending on the machine.
Markers were harvested from session.items in pytest_collection_finish, which
runs after every pytest_collection_modifyitems hook, so anything the runner
dropped vanished from the numerator: the weekly load test is deselected unless
E2E_WEEKLY_ANOMALY is set, and the MCP OAuth module sits behind
pytest.importorskip("mcp") / importorskip("playwright.async_api").
Markers are now read straight off the source with ast, so a cell counts when a
test declaring it exists, whatever the runner does with it. The collect-only
pytest pass stays for the two things the source text cannot give: markers built
at import time (pytest.mark.covers(*fn(...)) inside a pytest.param) and the
nodeids that failed to import, whose cells are genuinely unknowable and still
drive --fail-on-collection-errors. Files that cannot be parsed are reported the
same way instead of being dropped.
The TypeScript Playwright suite emits no pytest markers, so the two
surface: ui mgmt rows were structurally uncoverable. It now declares its cells
in tests/e2e/ui/coverage.yaml, which the collector unions in; an id there that
is not in the registry surfaces as an orphan marker exactly like a mistyped
pytest marker. Each row also names the spec and the Playwright test title behind
it, and both are resolved against the tree on every run, so renaming, deleting
or commenting out that test drops the cell out of the numerator and fails
--strict instead of leaving it counted forever.
Title matching is per declaration, never per file. Comments are stripped before
titles are read, by a string-aware pass that keeps literals intact so a "//"
inside a title is never mistaken for a comment opener. An interpolated title is
matched against its literal segments, so it is checked as far as it can be, and
a dynamic title elsewhere in the spec never exempts a row naming a literal one.
A title with no literal text, or one assembled from variables, backs no row, so
an unmatchable shape fails loudly rather than waving a whole file through. Only
mgmt.key.update.happy_path is claimed, proven by the "Update key TPM and RPM
limits" spec. mgmt.key.generate.happy_path is left unclaimed and documented:
that row scopes itself to SSO-driven key gen and every role in the suite logs in
with username/password.
Pruned llm.embeddings.anthropic.basic.nonstream.works. Anthropic ships no
embeddings API, the anthropic handler's embedding() is a pass stub, no anthropic
dispatch exists in embedding(), and no anthropic row in
model_prices_and_context_window.json carries mode: embedding, so the row could
never pass.
Also corrected the suite-folder list in tests/e2e/CLAUDE.md: gateway/,
embeddings/, security/, and a top-level realtime/ do not exist (realtime lives
under llm_translation/), and guardrails/ was undocumented.
Same tree now reports 316/430 with and without E2E_WEEKLY_ANOMALY and with or
without mcp/playwright installed.
This commit is contained in:
parent
bb6bb664b1
commit
b5028b81c6
6 changed files with 752 additions and 32 deletions
|
|
@ -4,25 +4,22 @@ Code-style rules for writing tests under `tests/e2e/`. The harness already encod
|
|||
|
||||
## Suite folders
|
||||
|
||||
Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family or behavior area. If you add a new folder, you must add a line here describing what kind of tests belong in it, so the layout stays self-describing. `gateway/` is the exception: it holds proxy configuration only and never tests
|
||||
Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family or behavior area. If you add a new folder, you must add a line here describing what kind of tests belong in it, so the layout stays self-describing. `coverage_registry/` is the exception: it holds the coverage denominator and its collector, not a suite
|
||||
|
||||
- `llm_translation/` - LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown
|
||||
- `llm_translation/` - LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown. Realtime websocket sessions, including the pipecat audio path, live in its `realtime/` subfolder
|
||||
- `access_control/` - the gateway's authorization and error-shape contract: per-key model allow-lists, route-group permissions (`allowed_routes`), and unknown-model validation
|
||||
- `embeddings/` - the `/embeddings` endpoint across providers
|
||||
- `batches/` - the `/batches` endpoint (placeholder until the first test lands)
|
||||
- `realtime/` - realtime websocket sessions, including the pipecat audio path
|
||||
- `batches/` - the `/batches` endpoint: the file-upload/create/retrieve/cancel lifecycle per provider
|
||||
- `guardrails/` - guardrail providers at each hook point: what they block, mask, or allow, and how team-level config turns a global guardrail off
|
||||
- `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `ratelimit/` (rpm/tpm blocks, window reset, pacing headers on live traffic), `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window), and `spend_tracking/` (spend logging and cost attribution on `/spend/*`)
|
||||
- `management/` - key/team/user/organization management routes: create/update/delete persistence via the info routes, team membership, and llm-only-key route denials (API surface; not Playwright)
|
||||
- `a2a/` - the A2A (agent-to-agent) surface: admin registration via `/v1/agents`, proxy-fronted card discovery at `/.well-known/agent-card.json`, and JSON-RPC `message/send` invocation, driving agents backed by the litellm completion bridge (a real provider) and asserting protocol-version normalization (0.3 vs 1.0)
|
||||
- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server (see "MCP suite: real Datadog only" below); plus the gateway-managed OAuth (authorization_code) path exercised through `/chat/completions`, the one behavior Datadog's static-header auth cannot reach, seeding the per-user upstream token via the interactive authorize dance driven with the mcp SDK's own OAuth client (headless-browser consent from a saved session) and asserting the completion lists and executes the server's tools with the stored per-user token
|
||||
- `logging/` - logging-integration delivery (datadog and friends)
|
||||
- `security/` - secret handling and log-leak protection
|
||||
- `router/` - routing and reliability behavior (fallbacks, cooldowns)
|
||||
- `load/` - throughput/performance under concurrency: drives real concurrent traffic through the whole stack with Locust and asserts a throughput SLO; marked `load` so the parent conftest collects it last and it never perturbs latency-sensitive suites. Also home of the weekly session-anomaly test (`test_weekly_session_anomaly_e2e.py`): Claude Code-shaped multi-turn sessions against real providers with ceilings on error rate, cache read/write, turn time, and spend; additionally marked `weekly` and deselected unless `E2E_WEEKLY_ANOMALY` is set, because it spends real provider money (driven by `.github/workflows/weekly_load_anomaly.yml`)
|
||||
- `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite
|
||||
- `gateway/` - proxy configuration only (`litellm-config.yml`); no tests
|
||||
- `claude_code/` - the Claude Code compatibility matrix: drives the real `claude` CLI (and HTTP probes) against a proxy for each feature x provider cell, reporting tagged-union outcomes via the `compat_result` fixture; ships its own driver/builder/publisher plus `_*_unit_tests/` trees. The HTTP probes ride the shared transport (`ProxyClient.count_tokens` / `ProxyClient.messages`); the CLI-driving path stays bespoke
|
||||
- `ui/` - the Admin UI browser suite: Playwright in TypeScript, driving the dashboard served by a live proxy on port 4000 (seeded postgres + mock LLM upstream; see its `run_e2e.sh`). It is a self-contained npm package with its own lockfile and does not use the Python harness, pytest markers, or the shared transport; the Python rules in this file (typed models, `Result` unions, basedpyright zero-error gate) do not apply inside it. Its only Python file, `fixtures/mock_llm_server/server.py`, is excluded from the e2e basedpyright gate via the root `pyrightconfig.json`
|
||||
- `ui/` - the Admin UI browser suite: Playwright in TypeScript, driving the dashboard served by a live proxy on port 4000 (seeded postgres + mock LLM upstream; see its `run_e2e.sh`). It is a self-contained npm package with its own lockfile and does not use the Python harness, pytest markers, or the shared transport; the Python rules in this file (typed models, `Result` unions, basedpyright zero-error gate) do not apply inside it. Its only Python file, `fixtures/mock_llm_server/server.py`, is excluded from the e2e basedpyright gate via the root `pyrightconfig.json`. Emitting no pytest markers, it declares the coverage cells it proves in `coverage.yaml` instead (see "Coverage registry" below)
|
||||
|
||||
## MCP suite: real Datadog only
|
||||
|
||||
|
|
@ -79,6 +76,8 @@ The harness is fully typed with no error budget: `make lint-e2e-basedpyright` mu
|
|||
|
||||
The set of tests we want is a registry checked into this repo, one row per behavior; that file is the definition of done and the denominator. Each e2e test declares what it covers with `@pytest.mark.covers("...")`, and a small collector diffs the registry against the tests and ships coverage to the existing Grafana. No Allure, no new dependencies
|
||||
|
||||
The number is a property of the source tree, not of the run: a cell counts as covered when a test declaring it exists, whether or not that test is selected, skipped, or passing on this machine. The collector reads the markers with `ast` so a deselected test (the `weekly` load test) and a module behind `pytest.importorskip` both keep counting; it runs a collect-only pytest pass only for markers built at import time and for nodes that fail to import. The TypeScript `ui/` suite emits no markers, so it declares its cells in `tests/e2e/ui/coverage.yaml`; add a row there when a UI spec proves a registry cell, and a wrong id shows up as an orphan marker just like a mistyped `covers`. Each row names the spec and the Playwright test title behind it and both are resolved against the tree, so renaming or deleting that test drops the cell and fails `--strict` rather than leaving it counted forever
|
||||
|
||||
Coverage is organized as module > feature > test. Dashboard modules are `Core LLMs`, `Non-Core LLMs`, `MCPs`, `Management/UI`, `Reliability & Performance`, `Quota Management`, `Logging & Guardrails`, and `Other`. The Loki stdout formatter maps those display modules to log-safe labels (`core_llms`, `non_core_llms`, `mcp`, `management_ui`, `reliability_performance`, `quota_management`, `logging_guardrails`, and `other`) without changing JSON or Prometheus labels. A feature is either an endpoint (`/chat/completions`) or a behavior (fallbacks, rate limits; config-driven, with no route of its own). A cell reads like `llm.chat_completions.bedrock_converse.tool_use.stream.works`
|
||||
|
||||
The metric is coverage: the share of registry rows that have a passing covering test, reported to Grafana per module so a gap surfaces as an uncovered row rather than a silent absence
|
||||
|
|
|
|||
|
|
@ -34,8 +34,34 @@ def test_openai_streaming_tool_calls(self) -> None:
|
|||
## The number
|
||||
|
||||
`collector.py` diffs the registry against those markers and reports coverage per module.
|
||||
It is static: a collect-only pass reads the markers, so it runs no test and needs no live
|
||||
proxy. Whether a covered cell currently passes or fails is a separate, live concern.
|
||||
It is static in the strong sense: a cell is covered when a test declaring it exists in
|
||||
the tree, so the number is a property of the source and does not move with the runner.
|
||||
The markers are read off the source with `ast`, which keeps deselected tests (the
|
||||
`weekly` load test, gated on `E2E_WEEKLY_ANOMALY`) and modules behind an optional
|
||||
dependency (`pytest.importorskip("mcp")`) counted on every machine. A collect-only
|
||||
pytest pass runs alongside it for the two things the source text cannot give: markers
|
||||
built at import time (`pytest.mark.covers(*fn(...))` inside a `pytest.param`) and the
|
||||
nodeids that failed to import, whose cells are genuinely unknowable. It runs no test
|
||||
and needs no live proxy. Whether a covered cell currently passes or fails is a
|
||||
separate, live concern.
|
||||
|
||||
The Playwright suite under `tests/e2e/ui/` is TypeScript and emits no pytest markers,
|
||||
so it declares the cells it covers in `tests/e2e/ui/coverage.yaml` and the collector
|
||||
unions those ids in. An id there that is not in the registry surfaces as an orphan
|
||||
marker exactly like a typo in a pytest marker.
|
||||
|
||||
Each row names the `spec` it lives in and the Playwright `test` title that proves it,
|
||||
and the collector resolves both against the tree on every run. Rename or delete that
|
||||
test and the row stops counting and is listed as a stale declaration, so a claim
|
||||
cannot outlive the test behind it. Comments are stripped before titles are read, so
|
||||
a test commented out and forgotten fails the same way a deleted one does, and the
|
||||
strip is string-aware so a `//` inside a title is never mistaken for a comment
|
||||
opener. An interpolated title is matched against its
|
||||
literal segments, so it is still checked as far as it can be, and the check is per
|
||||
row rather than per file: a dynamic title elsewhere in the spec never exempts a row
|
||||
that names a literal one. A title with no literal text, or one assembled from
|
||||
variables, backs no row at all, so the contributor has to give that test a
|
||||
matchable title instead of the check being waved through.
|
||||
|
||||
```
|
||||
cd tests/e2e && PYTHONPATH=. python -m coverage_registry.collector
|
||||
|
|
@ -64,8 +90,9 @@ cd tests/e2e && PYTHONPATH=. python -m coverage_registry.collector --strict
|
|||
```
|
||||
|
||||
Strict mode exits non-zero on `@pytest.mark.covers(...)` ids that are not checked into
|
||||
the registry. Add `--fail-on-collection-errors` when the job should also fail on pytest
|
||||
collection errors.
|
||||
the registry, and on UI declarations whose spec or test title no longer exists. Both are
|
||||
the same failure: the registry claims something the tree does not support. Add
|
||||
`--fail-on-collection-errors` when the job should also fail on pytest collection errors.
|
||||
|
||||
## Status: this is a draft for review
|
||||
|
||||
|
|
@ -74,8 +101,13 @@ things to settle before treating the set as final:
|
|||
|
||||
- tiers are proposed, not signed off; 125 P0 is a lot to prove fail-before-fix, so P0 may
|
||||
want tightening
|
||||
- a few cells need a support check or a prune (for example `llm.embeddings.anthropic.*`
|
||||
and `reliability.perf.throughput.under_slo`)
|
||||
- `llm.embeddings.anthropic.basic.nonstream.works` was removed: Anthropic ships no
|
||||
embeddings API, litellm has no anthropic embedding handler (the one on the chat
|
||||
handler is a `pass` stub and no anthropic row in
|
||||
`model_prices_and_context_window.json` carries `mode: embedding`), so the row could
|
||||
never pass. `reliability.perf.throughput.under_slo` still needs a support check
|
||||
- prune a row only when it is genuinely unreachable, with the evidence written down
|
||||
here; the denominator is the metric, and shrinking it for any other reason games it
|
||||
- auth is covered in two places (`other.auth.*` and the mgmt authz assertions); the
|
||||
boundary needs a decision, and the auth cluster may deserve promotion to its own module
|
||||
- the P2 "niche" cells each stand in for a large tail of integrations/providers by design,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,31 @@
|
|||
"""Diff the registry (denominator) against the @pytest.mark.covers markers on the
|
||||
live tests (numerator) and report coverage per module.
|
||||
|
||||
Coverage here is static: it reads the markers via a collect-only pass, so it runs
|
||||
no test and needs no live proxy. Whether a covered cell currently passes or fails
|
||||
(covered_pass vs covered_fail) is a separate, live concern layered on top later.
|
||||
Coverage here is static: a cell is covered when a test declaring it exists in the
|
||||
source tree. Whether that test is deselected on this run, skipped for a missing
|
||||
optional dependency, or currently passing is a runtime concern and must not move
|
||||
the number, so the markers are read straight off the source with `ast` rather
|
||||
than off whatever pytest happened to keep after collection. A collect-only pytest
|
||||
pass still runs alongside it, for two things the source text cannot give: markers
|
||||
built at import time (`pytest.mark.covers(*fn(...))` inside `pytest.param`) and
|
||||
the nodeids that failed to import, whose cells are genuinely unknowable.
|
||||
|
||||
The TypeScript Playwright suite under `tests/e2e/ui/` emits no pytest markers, so
|
||||
it declares the cells it covers in `tests/e2e/ui/coverage.yaml` instead. Each row
|
||||
names the spec and the test title that prove it, and both are resolved against
|
||||
the tree, so deleting or renaming that Playwright test drops the cell out of the
|
||||
numerator and fails `--strict` the same way an unknown cell id does.
|
||||
|
||||
cd tests/e2e && PYTHONPATH=. python -m coverage_registry.collector
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -20,12 +33,204 @@ from pathlib import Path
|
|||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .registry import load_registry
|
||||
from .schema import MODULE_ORDER, Cell, Tier, dashboard_module, loki_module_label
|
||||
|
||||
E2E_DIR = Path(__file__).resolve().parent.parent
|
||||
UI_DECLARATION_FILE = E2E_DIR / "ui" / "coverage.yaml"
|
||||
|
||||
_UNSCANNED_DIRS: frozenset[str] = frozenset(
|
||||
{"__pycache__", ".git", ".venv", "node_modules", "site-packages", "venv"}
|
||||
)
|
||||
|
||||
|
||||
def _is_covers_call(func: ast.expr) -> bool:
|
||||
"""True for `<anything>.mark.covers` and the `from pytest import mark` spelling."""
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "covers":
|
||||
return False
|
||||
owner = func.value
|
||||
if isinstance(owner, ast.Attribute):
|
||||
return owner.attr == "mark"
|
||||
return isinstance(owner, ast.Name) and owner.id == "mark"
|
||||
|
||||
|
||||
def _covers_ids_in_source(source: str) -> frozenset[str]:
|
||||
tree = ast.parse(source)
|
||||
return frozenset(
|
||||
arg.value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and _is_covers_call(node.func)
|
||||
for arg in node.args
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
||||
)
|
||||
|
||||
|
||||
def _scannable(path: Path) -> bool:
|
||||
return not _UNSCANNED_DIRS.intersection(path.parts)
|
||||
|
||||
|
||||
def _read_covers_ids(path: Path) -> frozenset[str] | None:
|
||||
"""The file's declared cell ids, or None when it cannot be parsed."""
|
||||
try:
|
||||
return _covers_ids_in_source(path.read_text(encoding="utf-8"))
|
||||
except (OSError, SyntaxError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def scan_covers_markers(
|
||||
e2e_dir: Path = E2E_DIR,
|
||||
) -> tuple[frozenset[str], tuple[str, ...]]:
|
||||
"""Return (cell ids declared by a `covers` marker anywhere in the source tree,
|
||||
paths that could not be parsed). Independent of deselection, of env-gated
|
||||
opt-ins, and of optional dependencies, because nothing is imported."""
|
||||
parsed = tuple(
|
||||
(path, _read_covers_ids(path))
|
||||
for path in sorted(e2e_dir.rglob("*.py"))
|
||||
if _scannable(path)
|
||||
)
|
||||
return (
|
||||
frozenset(
|
||||
cell_id for _, ids in parsed if ids is not None for cell_id in ids
|
||||
),
|
||||
tuple(str(path) for path, ids in parsed if ids is None),
|
||||
)
|
||||
|
||||
|
||||
class _UiCoveredCell(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
id: str
|
||||
spec: str
|
||||
test: str
|
||||
|
||||
|
||||
class _UiDeclaration(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
covers: tuple[_UiCoveredCell, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UiDeclarations:
|
||||
"""Cells the Playwright suite claims, split by whether the claim still holds."""
|
||||
|
||||
ids: frozenset[str]
|
||||
unresolved: tuple[str, ...]
|
||||
|
||||
|
||||
_TITLED_CALL = re.compile(
|
||||
r"\btest(?:\.(?:describe|only|skip|fixme|serial|parallel|step))*\s*\(\s*"
|
||||
)
|
||||
_TITLE_LITERAL = re.compile(r"(['\"`])((?:\\.|(?!\1).)*?)\1\s*[,)]", re.DOTALL)
|
||||
_INTERPOLATION = re.compile(r"\$\{[^{}]*\}")
|
||||
|
||||
_STRING_OR_COMMENT = re.compile(
|
||||
r"""
|
||||
(?P<string> '(?:\\.|[^'\\\n])*'
|
||||
| "(?:\\.|[^"\\\n])*"
|
||||
| `(?:\\.|[^`\\])*` )
|
||||
| (?P<comment> //[^\n]* | /\*.*?\*/ )
|
||||
""",
|
||||
re.VERBOSE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _without_comments(source: str) -> str:
|
||||
"""The source with its comments blanked out, so a commented-out test cannot
|
||||
back a declaration. String literals are matched by the same pass and kept
|
||||
intact, so a `//` inside a title (a URL, say) is never mistaken for a comment
|
||||
opener; a false negative there would be worse than the staleness this catches."""
|
||||
return _STRING_OR_COMMENT.sub(
|
||||
lambda match: match.group("string") or " ", source
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SpecTitles:
|
||||
"""What a spec's test titles can be matched against: the ones written out in
|
||||
full, and a pattern per interpolated title covering the titles it can produce."""
|
||||
|
||||
literal: frozenset[str]
|
||||
patterns: tuple[re.Pattern[str], ...]
|
||||
|
||||
def covers(self, title: str) -> bool:
|
||||
return title in self.literal or any(
|
||||
pattern.fullmatch(title) for pattern in self.patterns
|
||||
)
|
||||
|
||||
|
||||
def _title_pattern(template: str) -> re.Pattern[str] | None:
|
||||
"""A pattern for the titles an interpolated title can produce, or None when it
|
||||
is all interpolation and would therefore match anything."""
|
||||
segments = tuple(_INTERPOLATION.split(template))
|
||||
if not any(segment.strip() for segment in segments):
|
||||
return None
|
||||
return re.compile(".*".join(re.escape(segment) for segment in segments), re.DOTALL)
|
||||
|
||||
|
||||
def _spec_titles(spec_source: str) -> _SpecTitles:
|
||||
"""Every test title the spec can produce, read from the whole `test` family.
|
||||
|
||||
A first argument that is not a string literal contributes nothing: that covers
|
||||
`test.skip(condition, reason)`, which shares its name with the titled form, and
|
||||
a title assembled from variables, which cannot be matched by text at all. A
|
||||
declaration naming one of those fails loudly rather than being waved through."""
|
||||
source = _without_comments(spec_source)
|
||||
titles = tuple(
|
||||
(literal.group(1), literal.group(2))
|
||||
for call in _TITLED_CALL.finditer(source)
|
||||
if (literal := _TITLE_LITERAL.match(source, call.end())) is not None
|
||||
)
|
||||
return _SpecTitles(
|
||||
literal=frozenset(
|
||||
text for quote, text in titles if not (quote == "`" and "${" in text)
|
||||
),
|
||||
patterns=tuple(
|
||||
pattern
|
||||
for quote, text in titles
|
||||
if quote == "`" and "${" in text
|
||||
if (pattern := _title_pattern(text)) is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _unresolved_reason(cell: _UiCoveredCell, ui_dir: Path) -> str | None:
|
||||
"""Why this row no longer resolves against the suite, or None when it holds.
|
||||
|
||||
The check is per declaration, never per file: an interpolated title elsewhere
|
||||
in the spec is irrelevant unless it could itself have produced this title, so
|
||||
renaming a literal test still fails even when a dynamic sibling sits beside
|
||||
it."""
|
||||
spec = ui_dir / cell.spec
|
||||
if not spec.is_file():
|
||||
return f"{cell.id}: spec {cell.spec} does not exist"
|
||||
if _spec_titles(spec.read_text(encoding="utf-8")).covers(cell.test):
|
||||
return None
|
||||
return f"{cell.id}: {cell.spec} has no test titled {cell.test!r}"
|
||||
|
||||
|
||||
def load_ui_declarations(path: Path = UI_DECLARATION_FILE) -> UiDeclarations:
|
||||
"""Cells the TypeScript Playwright suite declares it covers, each checked
|
||||
against the spec and test title it names. A row whose spec or title no longer
|
||||
exists is dropped from the numerator and reported, so renaming or deleting a
|
||||
UI test cannot leave a cell counted forever. An id that resolves but is not in
|
||||
the registry lands in the covered set and surfaces as an orphan marker,
|
||||
exactly like a typo in a pytest marker."""
|
||||
if not path.is_file():
|
||||
return UiDeclarations(frozenset(), ())
|
||||
declaration = _UiDeclaration.model_validate(yaml.safe_load(path.read_text()) or {})
|
||||
checked = tuple(
|
||||
(cell, _unresolved_reason(cell, path.parent)) for cell in declaration.covers
|
||||
)
|
||||
return UiDeclarations(
|
||||
ids=frozenset(cell.id for cell, reason in checked if reason is None),
|
||||
unresolved=tuple(
|
||||
reason for _, reason in checked if reason is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _CoversSink:
|
||||
|
|
@ -71,6 +276,31 @@ def collect_covered_ids(
|
|||
return sink.covered_ids, sink.collection_errors
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoveredIds:
|
||||
ids: frozenset[str]
|
||||
collection_errors: tuple[str, ...]
|
||||
stale_ui_declarations: tuple[str, ...]
|
||||
|
||||
|
||||
def covered_ids(
|
||||
e2e_dir: Path = E2E_DIR,
|
||||
ui_declaration: Path = UI_DECLARATION_FILE,
|
||||
) -> CoveredIds:
|
||||
"""The numerator and everything that undermines it: every cell id declared in
|
||||
the source, plus the ones only a live import can resolve, plus the TypeScript
|
||||
suite's still-resolving declarations; every node that failed to parse or to
|
||||
import; and every UI declaration that no longer points at a real test."""
|
||||
scanned, unparseable = scan_covers_markers(e2e_dir)
|
||||
collected, import_errors = collect_covered_ids(e2e_dir)
|
||||
ui = load_ui_declarations(ui_declaration)
|
||||
return CoveredIds(
|
||||
ids=scanned | collected | ui.ids,
|
||||
collection_errors=(*unparseable, *import_errors),
|
||||
stale_ui_declarations=ui.unresolved,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleCoverage:
|
||||
module: str
|
||||
|
|
@ -94,6 +324,7 @@ class CoverageReport:
|
|||
p0_gaps: tuple[str, ...]
|
||||
orphan_markers: tuple[str, ...]
|
||||
collection_errors: tuple[str, ...]
|
||||
stale_ui_declarations: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def coverage_percent(self) -> float:
|
||||
|
|
@ -122,6 +353,7 @@ def compute_coverage(
|
|||
cells: tuple[Cell, ...],
|
||||
covered: frozenset[str],
|
||||
collection_errors: tuple[str, ...] = (),
|
||||
stale_ui_declarations: tuple[str, ...] = (),
|
||||
) -> CoverageReport:
|
||||
p0_cells = tuple(c for c in cells if c.tier is Tier.P0)
|
||||
registry_ids = frozenset(c.id for c in cells)
|
||||
|
|
@ -134,6 +366,7 @@ def compute_coverage(
|
|||
p0_gaps=tuple(sorted(c.id for c in p0_cells if c.id not in covered)),
|
||||
orphan_markers=tuple(sorted(covered - registry_ids)),
|
||||
collection_errors=collection_errors,
|
||||
stale_ui_declarations=stale_ui_declarations,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -161,16 +394,25 @@ def render(report: CoverageReport) -> str:
|
|||
if report.orphan_markers
|
||||
else ()
|
||||
)
|
||||
stale = (
|
||||
(
|
||||
f"\n{len(report.stale_ui_declarations)} UI declaration(s) no longer point at a "
|
||||
f"real test and are not counted (reconcile tests/e2e/ui/coverage.yaml):\n "
|
||||
+ "\n ".join(report.stale_ui_declarations),
|
||||
)
|
||||
if report.stale_ui_declarations
|
||||
else ()
|
||||
)
|
||||
warning = (
|
||||
(
|
||||
f"\nWARNING: {len(report.collection_errors)} node(s) failed to import during "
|
||||
f"collection, so coverage may undercount:\n "
|
||||
f"\nWARNING: {len(report.collection_errors)} node(s) failed to parse or import, "
|
||||
f"so coverage may undercount:\n "
|
||||
+ "\n ".join(report.collection_errors),
|
||||
)
|
||||
if report.collection_errors
|
||||
else ()
|
||||
)
|
||||
return "\n".join((*lines, *orphans, *warning))
|
||||
return "\n".join((*lines, *orphans, *stale, *warning))
|
||||
|
||||
|
||||
def _report_dict(report: CoverageReport) -> dict[str, object]:
|
||||
|
|
@ -191,6 +433,7 @@ def _report_dict(report: CoverageReport) -> dict[str, object]:
|
|||
],
|
||||
"orphan_markers": list(report.orphan_markers),
|
||||
"collection_errors": list(report.collection_errors),
|
||||
"stale_ui_declarations": list(report.stale_ui_declarations),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -237,6 +480,9 @@ def render_prometheus(report: CoverageReport) -> str:
|
|||
"# HELP litellm_e2e_coverage_collection_errors Pytest nodes that failed during collection.",
|
||||
"# TYPE litellm_e2e_coverage_collection_errors gauge",
|
||||
f"litellm_e2e_coverage_collection_errors {len(report.collection_errors)}",
|
||||
"# HELP litellm_e2e_coverage_stale_ui_declarations UI coverage declarations whose spec or test title no longer exists.",
|
||||
"# TYPE litellm_e2e_coverage_stale_ui_declarations gauge",
|
||||
f"litellm_e2e_coverage_stale_ui_declarations {len(report.stale_ui_declarations)}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
|
@ -260,12 +506,24 @@ def render_loki(report: CoverageReport) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
class _CliArgs(BaseModel):
|
||||
class CliArgs(BaseModel):
|
||||
format: Literal["text", "json", "prometheus", "loki"]
|
||||
strict: bool
|
||||
fail_on_collection_errors: bool
|
||||
|
||||
|
||||
def exit_code(report: CoverageReport, args: CliArgs) -> int:
|
||||
"""Non-zero when the run found something the operator asked to fail on: a claim
|
||||
the tree does not support (an unknown cell id or a dead UI declaration) under
|
||||
--strict, or a node whose cells could not be read under
|
||||
--fail-on-collection-errors."""
|
||||
if args.strict and (report.orphan_markers or report.stale_ui_declarations):
|
||||
return 1
|
||||
if args.fail_on_collection_errors and report.collection_errors:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument(
|
||||
|
|
@ -277,17 +535,22 @@ def main() -> int:
|
|||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Exit non-zero if markers outside the registry are found.",
|
||||
help=(
|
||||
"Exit non-zero if markers outside the registry are found, or if a UI "
|
||||
"coverage declaration no longer points at a real Playwright test."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fail-on-collection-errors",
|
||||
action="store_true",
|
||||
help="Exit non-zero if pytest collection errors are found.",
|
||||
)
|
||||
args = _CliArgs.model_validate(vars(parser.parse_args()))
|
||||
args = CliArgs.model_validate(vars(parser.parse_args()))
|
||||
cells = load_registry()
|
||||
covered, errors = collect_covered_ids()
|
||||
report = compute_coverage(cells, covered, errors)
|
||||
covered = covered_ids()
|
||||
report = compute_coverage(
|
||||
cells, covered.ids, covered.collection_errors, covered.stale_ui_declarations
|
||||
)
|
||||
output = {
|
||||
"text": render,
|
||||
"json": render_json,
|
||||
|
|
@ -295,11 +558,7 @@ def main() -> int:
|
|||
"loki": render_loki,
|
||||
}[args.format](report)
|
||||
print(output) # noqa: T201 # CLI entrypoint output
|
||||
if args.strict and report.orphan_markers:
|
||||
return 1
|
||||
if args.fail_on_collection_errors and report.collection_errors:
|
||||
return 1
|
||||
return 0
|
||||
return exit_code(report, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
- {id: llm.embeddings.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "llms/bedrock/embed/embedding.py", rationale: "Bedrock Titan embeddings"}
|
||||
- {id: llm.embeddings.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_embeddings/embedding_handler.py", rationale: "Vertex embeddings"}
|
||||
- {id: llm.embeddings.cohere.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: cohere, capability: basic, streaming: nonstream, assertions: [works], source: "llms/cohere/embed/handler.py", rationale: "Cohere embeddings"}
|
||||
- {id: llm.embeddings.anthropic.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: anthropic, capability: basic, streaming: nonstream, assertions: [works], source: "llms/anthropic/chat/handler.py", rationale: "Anthropic vector API (verify support)"}
|
||||
- {id: llm.batches.openai.create.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Core batch create"}
|
||||
- {id: llm.batches.openai.retrieve.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch retrieve, id round-trip + status"}
|
||||
- {id: llm.batches.openai.cancel.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch cancel"}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,23 @@ duplicate ids.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from coverage_registry.collector import (
|
||||
UI_DECLARATION_FILE,
|
||||
CliArgs,
|
||||
compute_coverage,
|
||||
covered_ids,
|
||||
exit_code,
|
||||
load_ui_declarations,
|
||||
render,
|
||||
render_json,
|
||||
render_loki,
|
||||
render_prometheus,
|
||||
scan_covers_markers,
|
||||
)
|
||||
from coverage_registry.registry import load_registry
|
||||
from coverage_registry.schema import (
|
||||
|
|
@ -24,6 +31,7 @@ from coverage_registry.schema import (
|
|||
LlmCell,
|
||||
LlmEndpoint,
|
||||
LoggingCell,
|
||||
MgmtCell,
|
||||
Tier,
|
||||
loki_module_label,
|
||||
)
|
||||
|
|
@ -192,3 +200,386 @@ def test_load_registry_rejects_duplicate_ids(tmp_path: Path) -> None:
|
|||
(tmp_path / "b.yaml").write_text(row)
|
||||
with pytest.raises(ValueError, match="duplicate cell ids"):
|
||||
load_registry(tmp_path)
|
||||
|
||||
|
||||
def test_registry_has_no_anthropic_embeddings_row() -> None:
|
||||
"""Anthropic ships no embeddings API and litellm has no handler for one, so a
|
||||
row asserting it can never pass. An unreachable row inflates the denominator
|
||||
forever."""
|
||||
llm_cells = tuple(c for c in load_registry() if isinstance(c, LlmCell))
|
||||
assert not [
|
||||
c
|
||||
for c in llm_cells
|
||||
if c.subject_endpoint == "embeddings" and c.route == "anthropic"
|
||||
]
|
||||
|
||||
|
||||
def _write(path: Path, source: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source)
|
||||
|
||||
|
||||
def _absent(tmp_path: Path) -> Path:
|
||||
return tmp_path / "no-ui-declaration.yaml"
|
||||
|
||||
|
||||
def test_marker_on_a_deselected_test_still_counts(tmp_path: Path) -> None:
|
||||
"""A cell is covered when a test declaring it exists. `tests/e2e/load/conftest.py`
|
||||
deselects the weekly anomaly test unless an env var is set, and a conftest that
|
||||
drops items must not be able to move the coverage number."""
|
||||
_write(
|
||||
tmp_path / "conftest.py",
|
||||
"import pytest\n\n\n"
|
||||
"def pytest_collection_modifyitems(config, items):\n"
|
||||
" dropped = [i for i in items if i.get_closest_marker('gated') is not None]\n"
|
||||
" config.hook.pytest_deselected(items=dropped)\n"
|
||||
" items[:] = [i for i in items if i.get_closest_marker('gated') is None]\n",
|
||||
)
|
||||
_write(
|
||||
tmp_path / "test_gated.py",
|
||||
"import pytest\n\n\n"
|
||||
"@pytest.mark.gated\n"
|
||||
"@pytest.mark.covers('llm.gated.cell')\n"
|
||||
"def test_gated() -> None:\n"
|
||||
" pass\n",
|
||||
)
|
||||
|
||||
result = covered_ids(tmp_path, _absent(tmp_path))
|
||||
ids, errors = result.ids, result.collection_errors
|
||||
|
||||
assert "llm.gated.cell" in ids
|
||||
assert errors == ()
|
||||
|
||||
|
||||
def test_marker_behind_an_optional_dependency_guard_still_counts(tmp_path: Path) -> None:
|
||||
"""`tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py` sits behind
|
||||
`pytest.importorskip`, so on a runner without the optional dep the module is
|
||||
never imported and pytest reports no items for it. Its cells still exist."""
|
||||
_write(
|
||||
tmp_path / "test_optional.py",
|
||||
"import pytest\n\n"
|
||||
"pytest.importorskip('a_package_that_is_not_installed_anywhere')\n\n\n"
|
||||
"@pytest.mark.covers('mcp.optional.cell')\n"
|
||||
"def test_optional() -> None:\n"
|
||||
" pass\n",
|
||||
)
|
||||
|
||||
result = covered_ids(tmp_path, _absent(tmp_path))
|
||||
ids, errors = result.ids, result.collection_errors
|
||||
|
||||
assert "mcp.optional.cell" in ids
|
||||
assert errors == ()
|
||||
|
||||
|
||||
def test_markers_built_at_import_time_still_count(tmp_path: Path) -> None:
|
||||
"""`batches/test_batches_e2e.py` computes its ids from a helper, so the source
|
||||
text alone cannot see them; the collect-only pytest pass has to stay."""
|
||||
_write(
|
||||
tmp_path / "test_dynamic.py",
|
||||
"import pytest\n\n\n"
|
||||
"def cells() -> tuple[str, ...]:\n"
|
||||
" return ('llm.' + 'dynamic.cell',)\n\n\n"
|
||||
"@pytest.mark.parametrize(\n"
|
||||
" 'case', [pytest.param('a', marks=pytest.mark.covers(*cells()))]\n"
|
||||
")\n"
|
||||
"def test_dynamic(case: str) -> None:\n"
|
||||
" pass\n",
|
||||
)
|
||||
|
||||
assert "llm.dynamic.cell" in covered_ids(tmp_path, _absent(tmp_path)).ids
|
||||
|
||||
|
||||
def test_module_that_cannot_be_imported_is_reported_as_a_collection_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_write(
|
||||
tmp_path / "test_broken.py",
|
||||
"import a_package_that_is_not_installed_anywhere # noqa: F401\n",
|
||||
)
|
||||
|
||||
errors = covered_ids(tmp_path, _absent(tmp_path)).collection_errors
|
||||
|
||||
assert any("test_broken.py" in error for error in errors)
|
||||
|
||||
|
||||
def test_unparseable_source_is_reported_rather_than_silently_dropped(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_write(tmp_path / "test_syntax.py", "def test_x(:\n")
|
||||
|
||||
ids, errors = scan_covers_markers(tmp_path)
|
||||
|
||||
assert ids == frozenset()
|
||||
assert any("test_syntax.py" in error for error in errors)
|
||||
|
||||
|
||||
_KEYS_SPEC = """import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Proxy Admin - Keys", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("Update key TPM and RPM limits", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
});
|
||||
|
||||
test.skip(!process.env.LITELLM_LICENSE, "proxy is running unlicensed");
|
||||
});
|
||||
"""
|
||||
|
||||
_UI_CELL = MgmtCell(
|
||||
id="mgmt.key.update.happy_path",
|
||||
module="mgmt",
|
||||
tier=Tier.P0,
|
||||
assertions=("happy_path",),
|
||||
source="t",
|
||||
surface="ui",
|
||||
)
|
||||
|
||||
|
||||
def _ui_suite(tmp_path: Path, *, spec: str = _KEYS_SPEC, title: str) -> Path:
|
||||
"""A miniature ui/ suite: one spec plus a declaration claiming `title` in it."""
|
||||
_write(tmp_path / "ui" / "tests" / "proxy-admin" / "keys.spec.ts", spec)
|
||||
declaration = tmp_path / "ui" / "coverage.yaml"
|
||||
_write(
|
||||
declaration,
|
||||
"covers:\n"
|
||||
" - id: mgmt.key.update.happy_path\n"
|
||||
" spec: tests/proxy-admin/keys.spec.ts\n"
|
||||
f" test: {json.dumps(title)}\n",
|
||||
)
|
||||
return declaration
|
||||
|
||||
|
||||
def test_ui_declaration_feeds_the_covered_set(tmp_path: Path) -> None:
|
||||
"""The TypeScript Playwright suite emits no pytest markers, so its cells reach
|
||||
the numerator through a checked-in declaration file instead."""
|
||||
declaration = _ui_suite(tmp_path, title="Update key TPM and RPM limits")
|
||||
|
||||
declared = load_ui_declarations(declaration)
|
||||
|
||||
assert declared.ids == frozenset({"mgmt.key.update.happy_path"})
|
||||
assert declared.unresolved == ()
|
||||
assert compute_coverage((_UI_CELL,), covered_ids(tmp_path, declaration).ids).covered == 1
|
||||
|
||||
|
||||
def test_ui_declaration_stops_counting_when_its_test_is_renamed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Renaming or deleting the Playwright test must drop the cell out of the
|
||||
numerator, so a claim here can never outlive the test that backs it."""
|
||||
declaration = _ui_suite(tmp_path, title="Update key TPM and RPM limits (renamed)")
|
||||
|
||||
declared = load_ui_declarations(declaration)
|
||||
|
||||
assert declared.ids == frozenset()
|
||||
assert declared.unresolved == (
|
||||
"mgmt.key.update.happy_path: tests/proxy-admin/keys.spec.ts has no test "
|
||||
"titled 'Update key TPM and RPM limits (renamed)'",
|
||||
)
|
||||
report = compute_coverage(
|
||||
(_UI_CELL,), declared.ids, stale_ui_declarations=declared.unresolved
|
||||
)
|
||||
assert report.covered == 0
|
||||
assert "no test titled" in render(report)
|
||||
|
||||
|
||||
def test_ui_declaration_stops_counting_when_its_spec_is_deleted(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
declaration = tmp_path / "ui" / "coverage.yaml"
|
||||
_write(
|
||||
declaration,
|
||||
"covers:\n"
|
||||
" - id: mgmt.key.update.happy_path\n"
|
||||
" spec: tests/proxy-admin/keys.spec.ts\n"
|
||||
" test: Update key TPM and RPM limits\n",
|
||||
)
|
||||
|
||||
declared = load_ui_declarations(declaration)
|
||||
|
||||
assert declared.ids == frozenset()
|
||||
assert declared.unresolved == (
|
||||
"mgmt.key.update.happy_path: spec tests/proxy-admin/keys.spec.ts does not exist",
|
||||
)
|
||||
|
||||
|
||||
_SPEC_WITH_DYNAMIC_SIBLING = """import { test } from "@playwright/test";
|
||||
|
||||
test(`${segment}: sidebar nav and reload`, async ({ page }) => {});
|
||||
|
||||
test("Update key rate limits", async ({ page }) => {});
|
||||
"""
|
||||
|
||||
|
||||
def test_a_dynamic_sibling_title_does_not_exempt_the_rest_of_the_spec(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The exemption is per declaration, never per file. A renamed literal test
|
||||
must still fail even when an interpolated title sits beside it in the same
|
||||
spec, otherwise one templated title switches validation off for the file."""
|
||||
declaration = _ui_suite(
|
||||
tmp_path,
|
||||
spec=_SPEC_WITH_DYNAMIC_SIBLING,
|
||||
title="Update key TPM and RPM limits",
|
||||
)
|
||||
|
||||
declared = load_ui_declarations(declaration)
|
||||
|
||||
assert declared.ids == frozenset()
|
||||
assert declared.unresolved == (
|
||||
"mgmt.key.update.happy_path: tests/proxy-admin/keys.spec.ts has no test "
|
||||
"titled 'Update key TPM and RPM limits'",
|
||||
)
|
||||
|
||||
|
||||
def test_an_interpolated_title_is_matched_by_its_literal_segments(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An interpolated title is still checked as far as it can be: the declared
|
||||
title has to be one the template could actually have produced."""
|
||||
declaration = _ui_suite(
|
||||
tmp_path,
|
||||
spec=_SPEC_WITH_DYNAMIC_SIBLING,
|
||||
title="api-keys: sidebar nav and reload",
|
||||
)
|
||||
|
||||
declared = load_ui_declarations(declaration)
|
||||
|
||||
assert declared.ids == frozenset({"mgmt.key.update.happy_path"})
|
||||
assert declared.unresolved == ()
|
||||
|
||||
|
||||
def test_a_title_the_template_cannot_produce_is_rejected(tmp_path: Path) -> None:
|
||||
declaration = _ui_suite(
|
||||
tmp_path,
|
||||
spec=_SPEC_WITH_DYNAMIC_SIBLING,
|
||||
title="api-keys: some other flow entirely",
|
||||
)
|
||||
|
||||
assert load_ui_declarations(declaration).ids == frozenset()
|
||||
|
||||
|
||||
def test_a_wholly_interpolated_title_backs_no_declaration(tmp_path: Path) -> None:
|
||||
"""A title that is nothing but interpolation would match anything, so it is
|
||||
not allowed to satisfy a row; the contributor has to give the test a title
|
||||
with something literal in it."""
|
||||
spec = (
|
||||
'import { test } from "@playwright/test";\n\n'
|
||||
"test(`${title}`, async ({ page }) => {});\n"
|
||||
)
|
||||
declaration = _ui_suite(tmp_path, spec=spec, title="Update key TPM and RPM limits")
|
||||
|
||||
assert load_ui_declarations(declaration).ids == frozenset()
|
||||
|
||||
|
||||
def test_a_commented_out_test_backs_no_declaration(tmp_path: Path) -> None:
|
||||
"""Commenting a test out while debugging and forgetting to restore it leaves
|
||||
the title sitting in the source. It must not keep the cell counted."""
|
||||
spec = (
|
||||
'import { test } from "@playwright/test";\n\n'
|
||||
'// test("Update key TPM and RPM limits", async ({ page }) => {});\n'
|
||||
)
|
||||
declaration = _ui_suite(tmp_path, spec=spec, title="Update key TPM and RPM limits")
|
||||
|
||||
assert load_ui_declarations(declaration).ids == frozenset()
|
||||
|
||||
|
||||
def test_a_block_commented_test_backs_no_declaration(tmp_path: Path) -> None:
|
||||
spec = (
|
||||
'import { test } from "@playwright/test";\n\n'
|
||||
"/*\n"
|
||||
'test("Update key TPM and RPM limits", async ({ page }) => {});\n'
|
||||
"*/\n"
|
||||
)
|
||||
declaration = _ui_suite(tmp_path, spec=spec, title="Update key TPM and RPM limits")
|
||||
|
||||
assert load_ui_declarations(declaration).ids == frozenset()
|
||||
|
||||
|
||||
def test_a_title_containing_a_url_still_validates(tmp_path: Path) -> None:
|
||||
"""Stripping comments must not touch `//` inside a string. A false negative
|
||||
here would be worse than the commented-out case it guards against."""
|
||||
title = "redirects to https://example.com/ui after login"
|
||||
spec = (
|
||||
'import { test } from "@playwright/test";\n\n'
|
||||
f'test("{title}", async ({{ page }}) => {{}});\n'
|
||||
)
|
||||
declaration = _ui_suite(tmp_path, spec=spec, title=title)
|
||||
|
||||
declared = load_ui_declarations(declaration)
|
||||
|
||||
assert declared.ids == frozenset({"mgmt.key.update.happy_path"})
|
||||
assert declared.unresolved == ()
|
||||
|
||||
|
||||
def test_a_comment_does_not_swallow_the_titles_after_it(tmp_path: Path) -> None:
|
||||
"""An apostrophe in a comment must not open a string that eats the rest of the
|
||||
file; the real specs are full of comments like this one."""
|
||||
spec = (
|
||||
'import { test } from "@playwright/test";\n\n'
|
||||
"// antd's dropdown renders off-viewport during the open animation\n"
|
||||
"/* the modal's footer button text varies between versions */\n"
|
||||
'test("Update key TPM and RPM limits", async ({ page }) => {});\n'
|
||||
)
|
||||
declaration = _ui_suite(tmp_path, spec=spec, title="Update key TPM and RPM limits")
|
||||
|
||||
assert load_ui_declarations(declaration).ids == frozenset(
|
||||
{"mgmt.key.update.happy_path"}
|
||||
)
|
||||
|
||||
|
||||
def test_a_title_assembled_from_variables_backs_no_declaration(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
spec = (
|
||||
'import { test } from "@playwright/test";\n\n'
|
||||
"test(TITLE, async ({ page }) => {});\n"
|
||||
)
|
||||
declaration = _ui_suite(tmp_path, spec=spec, title="Update key TPM and RPM limits")
|
||||
|
||||
assert load_ui_declarations(declaration).ids == frozenset()
|
||||
|
||||
|
||||
def test_stale_ui_declaration_fails_strict(tmp_path: Path) -> None:
|
||||
"""A dead declaration and an unknown cell id are the same failure: the tree no
|
||||
longer supports what the registry claims. Both must fail --strict."""
|
||||
declaration = _ui_suite(tmp_path, title="Update key TPM and RPM limits (renamed)")
|
||||
declared = load_ui_declarations(declaration)
|
||||
report = compute_coverage(
|
||||
(_UI_CELL,), declared.ids, stale_ui_declarations=declared.unresolved
|
||||
)
|
||||
strict = CliArgs(format="text", strict=True, fail_on_collection_errors=False)
|
||||
lenient = CliArgs(format="text", strict=False, fail_on_collection_errors=False)
|
||||
|
||||
assert exit_code(report, strict) == 1
|
||||
assert exit_code(report, lenient) == 0
|
||||
|
||||
|
||||
def test_ui_declaration_id_outside_the_registry_is_an_orphan(tmp_path: Path) -> None:
|
||||
declaration = _ui_suite(tmp_path, title="Update key TPM and RPM limits")
|
||||
_write(
|
||||
declaration,
|
||||
"covers:\n"
|
||||
" - id: mgmt.key.typo.happy_path\n"
|
||||
" spec: tests/proxy-admin/keys.spec.ts\n"
|
||||
" test: Update key TPM and RPM limits\n",
|
||||
)
|
||||
|
||||
report = compute_coverage(
|
||||
(_llm("llm.a", Tier.P0),), load_ui_declarations(declaration).ids
|
||||
)
|
||||
|
||||
assert report.covered == 0
|
||||
assert report.orphan_markers == ("mgmt.key.typo.happy_path",)
|
||||
|
||||
|
||||
def test_missing_ui_declaration_claims_nothing(tmp_path: Path) -> None:
|
||||
declared = load_ui_declarations(_absent(tmp_path))
|
||||
assert (declared.ids, declared.unresolved) == (frozenset(), ())
|
||||
|
||||
|
||||
def test_checked_in_ui_declaration_resolves_and_names_real_registry_cells() -> None:
|
||||
registry_ids = frozenset(c.id for c in load_registry())
|
||||
declared = load_ui_declarations(UI_DECLARATION_FILE)
|
||||
assert declared.unresolved == ()
|
||||
assert declared.ids <= registry_ids
|
||||
|
|
|
|||
40
tests/e2e/ui/coverage.yaml
Normal file
40
tests/e2e/ui/coverage.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Coverage-registry cells this Playwright suite covers.
|
||||
#
|
||||
# Every other e2e suite is Python and declares coverage with
|
||||
# @pytest.mark.covers("<cell id>"). This suite is TypeScript, so it declares the
|
||||
# same thing here and tests/e2e/coverage_registry/collector.py unions these ids
|
||||
# into the covered set.
|
||||
#
|
||||
# To add a row: write the UI test first, then name the registry cell it proves,
|
||||
# the spec file it lives in, and the test title. The id must exist in
|
||||
# tests/e2e/coverage_registry/*.yaml; a typo lands in the collector's orphan
|
||||
# marker list exactly like a typo in a pytest marker, and `--strict` fails on it.
|
||||
# Claim a cell only when the spec actually asserts that behavior.
|
||||
#
|
||||
# `spec` is relative to this file and `test` is the Playwright test title, and
|
||||
# the collector resolves both against the tree on every run. Rename or delete
|
||||
# that test and the row stops counting and fails `--strict`, so a claim here
|
||||
# cannot outlive the test that backs it.
|
||||
#
|
||||
# Commented-out code does not count as a test, so a row whose test is commented
|
||||
# out fails the same way a deleted one does.
|
||||
#
|
||||
# An interpolated title such as test(`${role} sidebar`) is checked against its
|
||||
# literal segments, so write out the title as it renders (e.g. "admin sidebar").
|
||||
# The check is per row, never per file: an interpolated title elsewhere in the
|
||||
# spec does not exempt your row. A title with no literal text at all, or one
|
||||
# assembled from variables, cannot back a row; give that test a title with
|
||||
# something literal in it.
|
||||
covers:
|
||||
- id: mgmt.key.update.happy_path
|
||||
spec: tests/proxy-admin/keys.spec.ts
|
||||
test: Update key TPM and RPM limits
|
||||
|
||||
# Not claimed yet:
|
||||
#
|
||||
# mgmt.key.generate.happy_path - the registry row scopes this to SSO-driven key
|
||||
# generation ("SSO-driven key gen (UI path)", source ui_sso.py). This suite
|
||||
# creates keys through the dashboard, but every role logs in with
|
||||
# username/password (globalSetup.ts), so no test drives the SSO path. Claim it
|
||||
# once a spec exercises SSO login, or retarget the registry row at plain
|
||||
# dashboard key creation and claim it then.
|
||||
Loading…
Add table
Reference in a new issue