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"