-
Notifications
You must be signed in to change notification settings - Fork 0
feat: behavior-vs-drift guard + anchor-aware heals + signed releases (v0.1.3) #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Example: Suggested fix: if failure.failure_type == FailureType.ASSERTION_FAILED:
...
elif failure.failure_type == FailureType.LOCATOR_NOT_FOUND:
print(f"{PAW} this failure needs a human ({tier.value}) β no automatic fix attempted.")
else:
raise ValueError(f"Unknown failure type: {failure.failure_type}")More Info
Prompt To Fix With AI
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dismissing. The generic "needs a human" message for all non-assertion Tier-3 types (navigation, flow-changed) is the intended graceful fallback β only the assertion case warrants the special "possible real bug" wording. The suggested fix ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, the original finding was wrong. The code is intentionally designed this way. Looking at the full picture:
Retracting the finding. The |
||
| 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", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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='<button id="login-btn-v2">Sign in</button>', | ||
| ) | ||
| 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='<button data-testid="submit" class="btn-new">Go</button>', | ||
| ) | ||
| result = asyncio.run(tier1_healer.heal(failure)) | ||
| assert result.success | ||
| assert result.metadata.get("anchor") == "testid" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test Example: Suggested fix: use 'id="login-btn"' as the failed_selector, or update _extract_identifiers to parse '#id' selectorsDetailed reasoningThe fix: either change the test's selector to More Info
Prompt To Fix With AI
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dismissing as a false positive. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Retracting β the finding was a false positive. Learned something new
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The GitHub release step references the literal path
9lives.cdx.json, but that file is only created if the preceding SBOM step succeeds. Becauseshopt -s nullglobonly affects glob expansions, a missing SBOM file causesgh release createto fail, contradicting the SBOM step'scontinue-on-error: trueand the 'best-effort; never blocks a release' comment. Change the literal path to a glob so nullglob can skip it when the SBOM step fails.Example:
Current:
Proposed:
dist/*.whl dist/*.tar.gz dist/*.sigstore* *.cdx.jsonMore Info
.github/workflows/release.ymlusescontinue-on-error: trueand writes9lives.cdx.json; the release step on the next block passes that literal filename togh release create.continue-on-error: trueon the SBOM step allows the job to continue, but the release step has no conditional guard for the literal file path.9lives.cdx.jsonto a glob like*.cdx.jsonsoshopt -s nullglobskips it when the SBOM step fails, or build the file list conditionally before callinggh release create.Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
β Confirmed real (P1) β fixed in #7. The literal
9lives.cdx.jsonis now*.cdx.jsonsonullglobdrops it when the best-effort SBOM step fails. v0.1.3 only shipped because that step happened to succeed. Good catch.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed β the P1 is resolved. The fix in #7 changed the literal
9lives.cdx.jsonpath in.github/workflows/release.yml:27to*.cdx.json, soshopt -s nullglob(already set earlier in the step) correctly drops the argument when the best-effort SBOM step fails and produces no file. v0.1.3 only shipped cleanly because the SBOM step happened to succeed; without the glob, any SBOM failure would have crashedgh release createdespitecontinue-on-error: true. No further action needed on this finding.