From b41e790baf7a6ff4dc1c3d6d7886fd57ac6943ac Mon Sep 17 00:00:00 2001 From: Timothy Haserjian Date: Mon, 16 Mar 2026 01:04:27 -0700 Subject: [PATCH] test(qa): add contention and secret-detection coverage with CI gates Add multi-agent contention simulation tests, secret-detection fixture corpus, classifier content_scan_exempt_globs policy, exemption regression test, and PR/nightly/release CI workflows. Signed-off-by: Timothy Haserjian Co-Authored-By: Claude Opus 4.6 (1M context) AgentMesh-Episode: ep_019cf5ac9f767b127bf41c8c AgentMesh-KeyID: mesh_a08cfb329abb0105 AgentMesh-Witness: sha256:5d30fa11c27ca2ae719768120593615498f009d0890217bd10cf2cfbe05c7a2e AgentMesh-Sig: UsLr28bZoUijQiIicOS50REK7FaY9FeUrwG4SbJhTbOcTYQ1fUsRsTfYJ-7Ed9shCDPpz6YcWPBNwMzCwR4aCw== AgentMesh-Witness-Encoding: gzip+base64url AgentMesh-Witness-Chunk-Count: 4 AgentMesh-Witness-Chunk: H4sIAIu5t2kC_0WST4_TMBDFv4uvbKux47HjSFw4gBBCC2KREJdoPB43YdOkatJ2YdXvjtPVwnX-_N6bZz8r2sm4tH1SjeKBTklayBBzQFZ3Sg79PJXSrS2HFnTgjMQhe-ejNj5mq7leR3M_yNzydBoX1Wj7Wuho7sru3JFB1zAKa5eM8UQx AgentMesh-Witness-Chunk: UMrJhsBcY6icqRxFWxVpXTEGMeKwLnz0tsaY0Xtjis6BFu5u2PYsx0hLv__Pz5TqGouK1eSyRkcFgylmnaniyD4k7Tix1SaRN8EmFk8OWYM4i-4fv0_tvFAcpLBRB6QALkWIwmIJEkUo3iVjqDVrDbXLt7xm7mRPq7G5n8Y10ou0Z712-t0o AgentMesh-Witness-Chunk: R9U8Kxp207FfutW2JIMFX_qP8vsl5b2UywhqzrEygWIEDbj6OsWh57bMlaHTt8dlnNpfPz7h-PlheTpfLvdff8of9-XD00cNm-H-4N6lMLz_Dm_V9U6VkKTcsz-UXQPGbaDaaPcAdQO2MX4L5QW8fQPQABStZZqG1yPmm-f1k6zOyj5swxbU AgentMesh-Witness-Chunk: 9foXEOkULzsCAAA= --- .agentmesh/policy.json | 9 + .../nightly-agentmesh-simulations.yml | 44 +++++ .github/workflows/pr-agentmesh-qa.yml | 38 ++++ .github/workflows/release-agentmesh-check.yml | 57 ++++++ src/agentmesh/public_private.py | 3 +- tests/fixtures/secrets/aws_key.py | 2 + tests/fixtures/secrets/clean_public.py | 5 + tests/fixtures/secrets/edge_ghp_in_comment.py | 6 + tests/fixtures/secrets/ghp_token.py | 2 + tests/fixtures/secrets/pricing_doc.md | 3 + tests/fixtures/secrets/private_key.pem | 4 + tests/test_contention.py | 162 ++++++++++++++++++ tests/test_public_private.py | 25 +++ tests/test_secret_detection.py | 158 +++++++++++++++++ 14 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 .agentmesh/policy.json create mode 100644 .github/workflows/nightly-agentmesh-simulations.yml create mode 100644 .github/workflows/pr-agentmesh-qa.yml create mode 100644 .github/workflows/release-agentmesh-check.yml create mode 100644 tests/fixtures/secrets/aws_key.py create mode 100644 tests/fixtures/secrets/clean_public.py create mode 100644 tests/fixtures/secrets/edge_ghp_in_comment.py create mode 100644 tests/fixtures/secrets/ghp_token.py create mode 100644 tests/fixtures/secrets/pricing_doc.md create mode 100644 tests/fixtures/secrets/private_key.pem create mode 100644 tests/test_contention.py create mode 100644 tests/test_secret_detection.py diff --git a/.agentmesh/policy.json b/.agentmesh/policy.json new file mode 100644 index 0000000..3e7cd58 --- /dev/null +++ b/.agentmesh/policy.json @@ -0,0 +1,9 @@ +{ + "public_private": { + "content_scan_exempt_globs": [ + "tests/fixtures/secrets/**", + "tests/test_secret_detection.py", + "tests/test_public_private.py" + ] + } +} diff --git a/.github/workflows/nightly-agentmesh-simulations.yml b/.github/workflows/nightly-agentmesh-simulations.yml new file mode 100644 index 0000000..697d9c1 --- /dev/null +++ b/.github/workflows/nightly-agentmesh-simulations.yml @@ -0,0 +1,44 @@ +name: Nightly QA Simulations + +on: + schedule: + # 03:15 UTC daily, offset from other nightly workflows + - cron: "15 3 * * *" + workflow_dispatch: + +jobs: + full-suite: + name: Full test suite + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run full test suite + run: pytest tests/ -v + + # Contention tests with extended timeout for heavier concurrency pressure + contention-heavy: + name: Contention tests (extended) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run contention tests with extended timeout + run: pytest tests/test_contention.py -v diff --git a/.github/workflows/pr-agentmesh-qa.yml b/.github/workflows/pr-agentmesh-qa.yml new file mode 100644 index 0000000..fbf7144 --- /dev/null +++ b/.github/workflows/pr-agentmesh-qa.yml @@ -0,0 +1,38 @@ +name: PR QA – Contention & Secret Detection + +on: + pull_request: + branches: [main] + +jobs: + contention: + name: Contention tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run contention tests + run: pytest tests/test_contention.py -v + + secret-detection: + name: Secret detection tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run secret detection tests + run: pytest tests/test_secret_detection.py -v diff --git a/.github/workflows/release-agentmesh-check.yml b/.github/workflows/release-agentmesh-check.yml new file mode 100644 index 0000000..646b555 --- /dev/null +++ b/.github/workflows/release-agentmesh-check.yml @@ -0,0 +1,57 @@ +name: Release Check + +on: + release: + types: [created] + workflow_dispatch: + +jobs: + build-and-verify: + name: Build, install, and verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build wheel + run: | + pip install build + python -m build + + - name: Install wheel in clean venv + run: | + python -m venv .release-venv + . .release-venv/bin/activate + pip install dist/*.whl + + - name: Verify CLI entry points + run: | + . .release-venv/bin/activate + agentmesh --version + agentmesh --help + agentmesh doctor --help + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: release-wheel + path: dist/ + + test-suite: + name: Full test suite against source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run full test suite + run: pytest tests/ -v diff --git a/src/agentmesh/public_private.py b/src/agentmesh/public_private.py index 0f5b5b5..16f4f26 100644 --- a/src/agentmesh/public_private.py +++ b/src/agentmesh/public_private.py @@ -117,6 +117,7 @@ def classify_path( private_globs = _policy_list(cfg.get("private_path_globs")) or _DEFAULT_PRIVATE_GLOBS review_globs = _policy_list(cfg.get("review_path_globs")) or _DEFAULT_REVIEW_GLOBS private_patterns = _policy_list(cfg.get("private_content_patterns")) or _DEFAULT_PRIVATE_PATTERNS + content_scan_exempt = _policy_list(cfg.get("content_scan_exempt_globs")) rel = _rel_path(path, repo_root) reasons: list[str] = [] @@ -125,7 +126,7 @@ def classify_path( reasons.append("path matches private pattern") content_marker = None - if path.exists() and path.is_file(): + if path.exists() and path.is_file() and not _has_match(rel, content_scan_exempt): try: text = path.read_text(errors="ignore") except OSError: diff --git a/tests/fixtures/secrets/aws_key.py b/tests/fixtures/secrets/aws_key.py new file mode 100644 index 0000000..f6afbeb --- /dev/null +++ b/tests/fixtures/secrets/aws_key.py @@ -0,0 +1,2 @@ +# This file contains a leaked AWS access key for testing secret detection. +AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE" diff --git a/tests/fixtures/secrets/clean_public.py b/tests/fixtures/secrets/clean_public.py new file mode 100644 index 0000000..d40a248 --- /dev/null +++ b/tests/fixtures/secrets/clean_public.py @@ -0,0 +1,5 @@ +"""A module with no secrets or sensitive content.""" + + +def greet(name: str) -> str: + return f"Hello, {name}!" diff --git a/tests/fixtures/secrets/edge_ghp_in_comment.py b/tests/fixtures/secrets/edge_ghp_in_comment.py new file mode 100644 index 0000000..470b49d --- /dev/null +++ b/tests/fixtures/secrets/edge_ghp_in_comment.py @@ -0,0 +1,6 @@ +"""Utility module with an accidentally pasted token in a comment.""" + + +def do_work() -> None: + # TODO: remove this token ghp_A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6 + pass diff --git a/tests/fixtures/secrets/ghp_token.py b/tests/fixtures/secrets/ghp_token.py new file mode 100644 index 0000000..01f156a --- /dev/null +++ b/tests/fixtures/secrets/ghp_token.py @@ -0,0 +1,2 @@ +# This file contains a leaked GitHub PAT for testing secret detection. +API_TOKEN = "ghp_R8x2mN4vL6pQ9wK1jT3yF5bA7cE0hU2sG4nM" diff --git a/tests/fixtures/secrets/pricing_doc.md b/tests/fixtures/secrets/pricing_doc.md new file mode 100644 index 0000000..d04c2ae --- /dev/null +++ b/tests/fixtures/secrets/pricing_doc.md @@ -0,0 +1,3 @@ +# Internal Strategy + +Our pricing model targets enterprise customers at $25K per seat. diff --git a/tests/fixtures/secrets/private_key.pem b/tests/fixtures/secrets/private_key.pem new file mode 100644 index 0000000..f6bacea --- /dev/null +++ b/tests/fixtures/secrets/private_key.pem @@ -0,0 +1,4 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBogIBAAJBALRiMLAHudeSA/x3hB2f+2NRkJLA/FAKEFAKEFAKEFAKEFAKE +FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE1234 +-----END RSA PRIVATE KEY----- diff --git a/tests/test_contention.py b/tests/test_contention.py new file mode 100644 index 0000000..e4f1280 --- /dev/null +++ b/tests/test_contention.py @@ -0,0 +1,162 @@ +"""Contention tests -- concurrent claim races, steal guards, and heavy weave append.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from agentmesh import db +from agentmesh.claims import make_claim, normalize_path +from agentmesh.models import Agent, ResourceType +from agentmesh.waiters import steal_resource +from agentmesh.weaver import append_weave, verify_weave + + +def _register(agent_id: str, data_dir: Path) -> None: + db.register_agent(Agent(agent_id=agent_id, cwd="/tmp"), data_dir) + + +# -- S1: same-file claim race -- + +def test_same_file_claim_race(tmp_data_dir: Path) -> None: + """Two agents race to claim the same file via ThreadPoolExecutor. + + Exactly one must win, one must get conflicts. No orphan active claims. + """ + _register("racer_a", tmp_data_dir) + _register("racer_b", tmp_data_dir) + + target = normalize_path("/tmp/contended.py") + + def _claim(agent_id: str) -> tuple[bool, list]: + ok, _clm, conflicts = make_claim(agent_id, "/tmp/contended.py", data_dir=tmp_data_dir) + return ok, conflicts + + with ThreadPoolExecutor(max_workers=2) as pool: + fut_a = pool.submit(_claim, "racer_a") + fut_b = pool.submit(_claim, "racer_b") + result_a = fut_a.result() + result_b = fut_b.result() + + wins = [r for r in [result_a, result_b] if r[0]] + losses = [r for r in [result_a, result_b] if not r[0]] + + # Exactly one winner, one loser + assert len(wins) == 1, f"Expected 1 winner, got {len(wins)}" + assert len(losses) == 1, f"Expected 1 loser, got {len(losses)}" + + # Loser must have received conflict info + loser_conflicts = losses[0][1] + assert len(loser_conflicts) >= 1, "Loser should see at least 1 conflict" + + # No orphan active claims: exactly 1 active edit claim on that path + all_active = db.list_claims(tmp_data_dir, active_only=True) + active_on_target = [c for c in all_active if c.path == target] + assert len(active_on_target) == 1, ( + f"Expected exactly 1 active claim on target, got {len(active_on_target)}" + ) + + +# -- S2: port/resource double-claim race -- + +def test_port_double_claim_race(tmp_data_dir: Path) -> None: + """Two agents race for PORT:3000; exactly one winner. + + db.list_claims(active_only=True) for that port has exactly 1 entry. + """ + _register("port_a", tmp_data_dir) + _register("port_b", tmp_data_dir) + + def _claim(agent_id: str) -> bool: + ok, _clm, _conflicts = make_claim(agent_id, "PORT:3000", data_dir=tmp_data_dir) + return ok + + with ThreadPoolExecutor(max_workers=2) as pool: + fut_a = pool.submit(_claim, "port_a") + fut_b = pool.submit(_claim, "port_b") + ok_a = fut_a.result() + ok_b = fut_b.result() + + # Exactly one winner + assert ok_a != ok_b, f"Expected exactly one winner: a={ok_a}, b={ok_b}" + + # Exactly 1 active claim for PORT:3000 + all_active = db.list_claims(tmp_data_dir, active_only=True) + port_claims = [ + c for c in all_active + if c.resource_type == ResourceType.PORT and c.path == "3000" + ] + assert len(port_claims) == 1, ( + f"Expected exactly 1 active PORT:3000 claim, got {len(port_claims)}" + ) + + +# -- S4: steal against fresh holder fails -- + +def test_steal_against_fresh_holder_fails(tmp_data_dir: Path) -> None: + """Agent tries to steal while holder is active with fresh heartbeat. + + Must fail. Holder retains claim. + """ + _register("holder", tmp_data_dir) + _register("thief", tmp_data_dir) + + # Holder claims the file with a long TTL + ok, _clm, _conflicts = make_claim( + "holder", "/tmp/guarded.py", ttl_s=3600, data_dir=tmp_data_dir, + ) + assert ok, "Holder should acquire claim" + + # Refresh holder heartbeat (make it current) + db.update_heartbeat("holder", data_dir=tmp_data_dir) + + target = normalize_path("/tmp/guarded.py") + + # Thief attempts steal with a short stale threshold + stolen, msg = steal_resource( + "thief", target, reason="hostile takeover", stale_threshold_s=300, + data_dir=tmp_data_dir, + ) + assert not stolen, f"Steal should fail, but succeeded with msg: {msg}" + assert "still active" in msg + + # Holder retains their active claim + holder_claims = db.list_claims(tmp_data_dir, agent_id="holder", active_only=True) + holder_on_target = [c for c in holder_claims if c.path == target] + assert len(holder_on_target) == 1, "Holder must still own the claim" + + # Thief has no active claim on that path + thief_claims = db.list_claims(tmp_data_dir, agent_id="thief", active_only=True) + thief_on_target = [c for c in thief_claims if c.path == target] + assert len(thief_on_target) == 0, "Thief must not hold any claim" + + +# -- S11: heavy concurrent weave append -- + +def test_heavy_concurrent_weave_append(tmp_data_dir: Path) -> None: + """50 concurrent writers via ThreadPoolExecutor(max_workers=16). + + All must get unique monotonic sequence IDs, no gaps, verify_weave() passes. + """ + n = 50 + + def _append(i: int) -> int: + evt = append_weave(capsule_id=f"heavy_{i}", data_dir=tmp_data_dir) + return evt.sequence_id + + with ThreadPoolExecutor(max_workers=16) as pool: + seqs = list(pool.map(_append, range(n))) + + # All sequence IDs must be unique + assert len(set(seqs)) == n, ( + f"Expected {n} unique sequence IDs, got {len(set(seqs))}" + ) + + # Sorted sequence IDs must form a contiguous range 1..n (no gaps) + assert sorted(seqs) == list(range(1, n + 1)), ( + f"Sequence IDs not contiguous 1..{n}: {sorted(seqs)}" + ) + + # Hash chain must verify cleanly + valid, err = verify_weave(tmp_data_dir) + assert valid, f"Weave verification failed: {err}" diff --git a/tests/test_public_private.py b/tests/test_public_private.py index 2b98d89..aebb837 100644 --- a/tests/test_public_private.py +++ b/tests/test_public_private.py @@ -114,6 +114,31 @@ def test_classify_honors_policy_overrides(tmp_path: Path, monkeypatch) -> None: assert payload["results"][0]["classification"] == PUBLIC +def test_content_scan_exempt_skips_secret_detection(tmp_path: Path) -> None: + """A file with secret content under an exempt glob is NOT flagged private.""" + repo = tmp_path / "repo" + (repo / ".agentmesh").mkdir(parents=True) + (repo / "tests" / "fixtures" / "secrets").mkdir(parents=True) + (repo / "tests" / "fixtures" / "secrets" / "token.py").write_text( + 'API_TOKEN = "ghp_R8x2mN4vL6pQ9wK1jT3yF5bA7cE0hU2sG4nM"\n' + ) + (repo / ".agentmesh" / "policy.json").write_text( + json.dumps( + { + "public_private": { + "content_scan_exempt_globs": ["tests/fixtures/secrets/**"], + } + } + ) + ) + + result = classify_path( + repo / "tests" / "fixtures" / "secrets" / "token.py", repo_root=repo, + ) + assert result.classification == PUBLIC + assert all("content matches" not in r for r in result.reasons) + + def test_classify_docs_public_json_as_public(tmp_path: Path, monkeypatch) -> None: repo = tmp_path / "repo" (repo / "docs").mkdir(parents=True) diff --git a/tests/test_secret_detection.py b/tests/test_secret_detection.py new file mode 100644 index 0000000..cb788ea --- /dev/null +++ b/tests/test_secret_detection.py @@ -0,0 +1,158 @@ +"""Tests for secret/sensitive-content detection via classify_path(). + +Each test uses a real fixture file from tests/fixtures/secrets/ and verifies +that the content-scanning branch of classify_path() correctly flags private +patterns (or does not overfire on clean controls). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agentmesh.public_private import PRIVATE, PUBLIC, classify_path + +FIXTURES = Path(__file__).parent / "fixtures" / "secrets" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _classify_fixture(name: str, tmp_path: Path) -> tuple[str, list[str]]: + """Copy a fixture into a fake repo under src/ and classify it. + + Placing the file under src/ ensures the *path* would match the default + PUBLIC glob. Any PRIVATE result therefore proves that content scanning + overrode the path classification. + """ + fixture = FIXTURES / name + repo = tmp_path / "repo" + src = repo / "src" + src.mkdir(parents=True) + dest = src / name + dest.write_bytes(fixture.read_bytes()) + result = classify_path(dest, repo_root=repo) + return result.classification, result.reasons + + +# --------------------------------------------------------------------------- +# Parameterised: specimens that MUST be classified as PRIVATE +# --------------------------------------------------------------------------- + +_PRIVATE_SPECIMENS = [ + pytest.param( + "ghp_token.py", + r"ghp_[A-Za-z0-9]{20,}", + id="github-pat", + ), + pytest.param( + "aws_key.py", + r"AKIA[0-9A-Z]{16}", + id="aws-access-key", + ), + pytest.param( + "private_key.pem", + r"PRIVATE KEY", + id="pem-private-key", + ), + pytest.param( + "pricing_doc.md", + r"pricing", + id="business-pricing", + ), + pytest.param( + "edge_ghp_in_comment.py", + r"ghp_[A-Za-z0-9]{20,}", + id="ghp-in-comment", + ), +] + + +@pytest.mark.parametrize("fixture_name, expected_pattern_fragment", _PRIVATE_SPECIMENS) +def test_private_specimen_detected( + fixture_name: str, + expected_pattern_fragment: str, + tmp_path: Path, +) -> None: + classification, reasons = _classify_fixture(fixture_name, tmp_path) + assert classification == PRIVATE, ( + f"{fixture_name} should be PRIVATE but got {classification}" + ) + combined = " ".join(reasons) + assert "content matches private pattern" in combined, ( + f"expected content-based reason, got {reasons}" + ) + assert expected_pattern_fragment.lower() in combined.lower(), ( + f"expected pattern fragment {expected_pattern_fragment!r} in reasons: {reasons}" + ) + + +# --------------------------------------------------------------------------- +# Clean control: must NOT be classified as PRIVATE +# --------------------------------------------------------------------------- + +def test_clean_public_not_private(tmp_path: Path) -> None: + classification, reasons = _classify_fixture("clean_public.py", tmp_path) + assert classification == PUBLIC, ( + f"clean_public.py should be PUBLIC but got {classification}: {reasons}" + ) + for reason in reasons: + assert "content matches private pattern" not in reason + + +# --------------------------------------------------------------------------- +# Additional business-sensitive pattern coverage +# --------------------------------------------------------------------------- + +_BUSINESS_SENSITIVE_CONTENT = [ + pytest.param("Our go to market strategy is bold.\n", r"go[- ]to[- ]market", id="go-to-market-spaces"), + pytest.param("The go-to-market plan launches Q3.\n", r"go[- ]to[- ]market", id="go-to-market-hyphens"), + pytest.param("Competitive positioning against X.\n", r"competitive positioning", id="competitive-positioning"), +] + + +@pytest.mark.parametrize("content, expected_fragment", _BUSINESS_SENSITIVE_CONTENT) +def test_business_sensitive_inline( + content: str, + expected_fragment: str, + tmp_path: Path, +) -> None: + """Verify business-sensitive phrases trigger PRIVATE even under src/.""" + repo = tmp_path / "repo" + src = repo / "src" + src.mkdir(parents=True) + target = src / "memo.txt" + target.write_text(content) + result = classify_path(target, repo_root=repo) + assert result.classification == PRIVATE + combined = " ".join(result.reasons).lower() + assert expected_fragment.lower() in combined + + +# --------------------------------------------------------------------------- +# Home-path leakage pattern +# --------------------------------------------------------------------------- + +_HOME_PATH_CONTENT = [ + pytest.param("/Users/developer/project/data.csv\n", r"/Users/", id="macos-home"), + pytest.param("/home/deploy/.config/secret.yml\n", r"/home/", id="linux-home"), +] + + +@pytest.mark.parametrize("content, expected_fragment", _HOME_PATH_CONTENT) +def test_home_path_leakage( + content: str, + expected_fragment: str, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + src = repo / "src" + src.mkdir(parents=True) + target = src / "config.txt" + target.write_text(content) + result = classify_path(target, repo_root=repo) + assert result.classification == PRIVATE + combined = " ".join(result.reasons) + assert expected_fragment in combined