diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 659ec91..2a1830a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,11 +20,31 @@ jobs: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv build + # Publish with PEP 740 attestations — PyPI records signed build provenance + # (repo + workflow + commit) that anyone can verify. No token, OIDC only. - uses: pypa/gh-action-pypi-publish@release/v1 - - name: GitHub release + with: + attestations: true + # CycloneDX SBOM of the built wheel (best-effort; never blocks a release). + - name: SBOM (CycloneDX) + continue-on-error: true + run: | + uv venv /tmp/sbomenv + uv pip install --python /tmp/sbomenv dist/*.whl + uvx --from cyclonedx-bom cyclonedx-py environment /tmp/sbomenv -o 9lives.cdx.json + # Sigstore-sign the artifacts; the .sigstore bundles ship on the release + # so installs from GitHub can be cryptographically verified too. + - name: Sign artifacts with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: ./dist/*.whl ./dist/*.tar.gz + - name: GitHub release (artifacts + signatures + SBOM) env: GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes + run: | + shopt -s nullglob + gh release create "$GITHUB_REF_NAME" --generate-notes \ + dist/*.whl dist/*.tar.gz dist/*.sigstore* 9lives.cdx.json action-major-tag: # Keep `uses: quality-max/9lives/action@v1` pointing at the newest v1.x.y diff --git a/README.md b/README.md index 178ce80..5c50384 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Your coding agent shipped a change and your Playwright test went red? Don't rewr 2. **Tier 2 — the subscription you already pay for.** Structural change? 9lives shells out to your installed coding-agent CLI — **Claude Code (`claude -p`), Codex (`codex exec`), or OpenCode (`opencode run`)** — so your existing subscription does the thinking. No API key to mint, nothing to configure: if the CLI is logged in, healing works. (Prefer raw API? `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` work too.) 3. **Always a diff, never a surprise.** Healed code is shown as a unified diff and applied only when you approve (or `--yes` in CI). +**Won't hide your bugs.** A failing *assertion* means the app's behavior changed — not that a selector moved. 9lives refuses to rewrite assertions to force a green (that's how naive auto-healers mask regressions) and flags it as a possible real bug instead. Opt in with `NINELIVES_HEAL_ASSERTIONS=1` if you really want it to propose an assertion update. + ## Install ```bash diff --git a/pyproject.toml b/pyproject.toml index 7940c54..6355ca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "9lives" -version = "0.1.2" +version = "0.1.3" description = "Self-healing QA for the coding-agent era. Your tests have nine lives." readme = "README.md" license = { text = "MIT" } diff --git a/src/ninelives/cli.py b/src/ninelives/cli.py index 55a8646..ddc55e4 100644 --- a/src/ninelives/cli.py +++ b/src/ninelives/cli.py @@ -20,7 +20,7 @@ from . import __version__ from .healing.parse import extract_failed_selector from .healing.patch import diff_stats, generate_unified_diff -from .healing.strategy import HealingTier, TestFailure, healing_strategy_selector +from .healing.strategy import FailureType, HealingTier, TestFailure, healing_strategy_selector from .healing.tier1 import tier1_healer from .healing.tier2 import Tier2AISuggest from .llm.agent_cli import detect_agent_clis @@ -194,7 +194,14 @@ def _heal_loop(spec: Path, working_spec: Path, *, auto_apply: bool, max_iteratio elif tier == HealingTier.TIER2_AI_SUGGEST: healing = asyncio.run(tier2.suggest(failure)) else: - print(f"{PAW} this failure needs a human ({tier.value}) — no automatic fix attempted.") + if failure.failure_type == FailureType.ASSERTION_FAILED: + # Behavior-vs-drift guard: never rewrite a failing assertion to + # force a pass — that hides a real bug. Flag it for a human. + print(f"{PAW} possible real bug — an assertion failed (behavior changed, not a selector).") + print(" 9lives won't rewrite assertions to force a pass. Review it, or set") + print(" NINELIVES_HEAL_ASSERTIONS=1 to let Tier 2 propose an assertion update.") + else: + print(f"{PAW} this failure needs a human ({tier.value}) — no automatic fix attempted.") return SpecOutcome( spec=spec.name, status="needs-human", diff --git a/src/ninelives/healing/strategy.py b/src/ninelives/healing/strategy.py index 0fc03cf..51b23f3 100644 --- a/src/ninelives/healing/strategy.py +++ b/src/ninelives/healing/strategy.py @@ -6,6 +6,7 @@ """ import logging +import os import re from dataclasses import dataclass, field from enum import Enum @@ -198,8 +199,12 @@ def select_strategy(self, failure: TestFailure) -> HealingTier: return HealingTier.TIER1_AUTO return HealingTier.TIER2_AI_SUGGEST + # Behavior-vs-drift guard: an assertion failure means the app behaved + # differently — not that a selector moved. Auto-rewriting the assertion + # to force a pass would hide a real bug, so 9lives never heals it and + # hands it to a human. Opt in explicitly with NINELIVES_HEAL_ASSERTIONS=1. if failure_type == FailureType.ASSERTION_FAILED: - if self._is_value_change(failure): + if os.environ.get("NINELIVES_HEAL_ASSERTIONS") == "1" and self._is_value_change(failure): return HealingTier.TIER2_AI_SUGGEST return HealingTier.TIER3_HUMAN diff --git a/src/ninelives/healing/tier1.py b/src/ninelives/healing/tier1.py index 6d0d77a..a397b57 100644 --- a/src/ninelives/healing/tier1.py +++ b/src/ninelives/healing/tier1.py @@ -31,17 +31,19 @@ async def heal(self, failure: TestFailure) -> HealingResult: if not failure.test_code: return self._no_heal_result("No test code provided") - alternative = self._find_alternative_selector(failure) - if alternative == failure.failed_selector: - alternative = None # same selector is not a heal - if alternative: + found = self._find_alternative_selector(failure) + if found and found[0] == failure.failed_selector: + found = None # same selector is not a heal + if found: + alternative, anchor = found healed_code = self._replace_selector(failure.test_code, failure.failed_selector, alternative) return HealingResult( tier=HealingTier.TIER1_AUTO, success=True, healed_code=healed_code, - changes_made=[f"Replaced selector '{failure.failed_selector}' with '{alternative}'"], + changes_made=[f"Re-found via {anchor}: replaced '{failure.failed_selector}' with '{alternative}'"], confidence=0.85, + metadata={"anchor": anchor}, ) transformed = self._try_transformations(failure.failed_selector) @@ -67,24 +69,28 @@ async def heal(self, failure: TestFailure) -> HealingResult: return self._no_heal_result("Could not find working alternative") - def _find_alternative_selector(self, failure: TestFailure) -> str | None: - """Find an alternative selector in the captured page state.""" + def _find_alternative_selector(self, failure: TestFailure) -> tuple[str, str] | None: + """Find an alternative selector in the captured page state. + + Returns (selector, anchor_type) — the anchor that re-identified the + element — so the report can show HOW it was re-found (e.g. "re-found + via testid"), or None when no anchor still resolves. + """ if not failure.page_html: return None - for identifier in self._extract_identifiers(failure.failed_selector): - alternative = self._find_in_html(identifier, failure.page_html) + for id_type, id_value in self._extract_identifiers(failure.failed_selector): + alternative = self._find_in_html((id_type, id_value), failure.page_html) if alternative: - return alternative + return alternative, id_type return None def _extract_identifiers(self, selector: str) -> list[tuple[str, str]]: """Extract identifying parts from a selector.""" identifiers = [] - text_match = re.search(r"text=['\"]([^'\"]+)['\"]", selector) - if text_match: - identifiers.append(("text", text_match.group(1))) - + # Stability order: testid > id > aria-label > text > class. We adopt the + # first anchor that still resolves on the live page, so the most durable + # identity wins and fragile copy/CSS churn is only the last resort. testid_match = re.search(r"data-testid=['\"]([^'\"]+)['\"]", selector) if testid_match: identifiers.append(("testid", testid_match.group(1))) @@ -93,13 +99,17 @@ def _extract_identifiers(self, selector: str) -> list[tuple[str, str]]: if id_match: identifiers.append(("id", id_match.group(1))) - for cls in re.findall(r"\.([a-zA-Z][\w-]*)", selector): - identifiers.append(("class", cls)) - aria_match = re.search(r"aria-label=['\"]([^'\"]+)['\"]", selector) if aria_match: identifiers.append(("aria-label", aria_match.group(1))) + text_match = re.search(r"text=['\"]([^'\"]+)['\"]", selector) + if text_match: + identifiers.append(("text", text_match.group(1))) + + for cls in re.findall(r"\.([a-zA-Z][\w-]*)", selector): + identifiers.append(("class", cls)) + return identifiers def _find_in_html(self, identifier: tuple[str, str], html: str) -> str | None: diff --git a/src/ninelives/report/github.py b/src/ninelives/report/github.py index ef1ca7f..249bf17 100644 --- a/src/ninelives/report/github.py +++ b/src/ninelives/report/github.py @@ -14,7 +14,9 @@ logger = logging.getLogger(__name__) COMMENT_BODY_FILENAME = ".9lives-report.md" -FOOTER = "🐾 checked by [9lives](https://9lives.run) — `curl -sL 9lives.run | sh`" +FOOTER = ( + "🐾 healed by [9lives](https://9lives.run) — self-healing Playwright for the coding-agent era · `curl -sL 9lives.run | sh`" +) _STATUS_EMOJI = { "passed": "✅", diff --git a/tests/test_healing.py b/tests/test_healing.py index ac22fe3..160b67b 100644 --- a/tests/test_healing.py +++ b/tests/test_healing.py @@ -368,3 +368,52 @@ def test_preflight_project_missing_playwright_raises(tmp_path): raised = True assert "@playwright/test" in str(exc) assert raised, "expected RunnerError when the enclosing project lacks @playwright/test" + + +def test_assertion_failure_is_not_auto_healed(): + # Behavior-vs-drift guard: a failing assertion is a possible real bug, so it + # goes to a human — never a Tier 2 rewrite that would force the test green. + failure = TestFailure( + failure_type=FailureType.ASSERTION_FAILED, + error_message="Error: expect(received).toBe(expected)\nExpected: 5\nReceived: 4", + ) + assert healing_strategy_selector.select_strategy(failure) == HealingTier.TIER3_HUMAN + + +def test_assertion_heal_is_opt_in(monkeypatch): + failure = TestFailure( + failure_type=FailureType.ASSERTION_FAILED, + error_message="Expected: 5 Received: 4", + ) + monkeypatch.setenv("NINELIVES_HEAL_ASSERTIONS", "1") + assert healing_strategy_selector.select_strategy(failure) == HealingTier.TIER2_AI_SUGGEST + + +def test_tier1_records_winning_anchor(): + # Anchor redundancy: the heal reports WHICH anchor re-identified the element. + failure = TestFailure( + failure_type=FailureType.LOCATOR_NOT_FOUND, + error_message="waiting for locator('#login-btn')", + failed_selector="#login-btn", + test_code="await page.locator('#login-btn').click();", + page_html='', + ) + result = asyncio.run(tier1_healer.heal(failure)) + assert result.success + assert result.metadata.get("anchor") == "id" + assert "re-found via id" in result.changes_made[0].lower() + + +def test_tier1_prefers_stable_anchor_over_class(): + # A selector carrying both a testid and a class should re-find via the + # stabler testid, not the fragile class. + failure = TestFailure( + failure_type=FailureType.LOCATOR_NOT_FOUND, + error_message="not found", + failed_selector="[data-testid='submit'].btn-old", + test_code="await page.locator(\"[data-testid='submit'].btn-old\").click();", + page_html='', + ) + result = asyncio.run(tier1_healer.heal(failure)) + assert result.success + assert result.metadata.get("anchor") == "testid"