diff --git a/backend-compliance/compliance/api/agent_check/_b17_wiring.py b/backend-compliance/compliance/api/agent_check/_b17_wiring.py
index 731247b9..892c7813 100644
--- a/backend-compliance/compliance/api/agent_check/_b17_wiring.py
+++ b/backend-compliance/compliance/api/agent_check/_b17_wiring.py
@@ -98,6 +98,10 @@ def _render(walk: dict) -> str:
detail = "Kein Accept-Button gefunden"
elif act == "discover_footer_links":
detail = f"{a.get('count', 0)} Compliance-Links im Footer"
+ elif act == "expand_accordions":
+ n = a.get("expanded", 0)
+ detail = (f"{n} Akkordeon/Details-Sektion(en) entfaltet"
+ if n else "Keine Akkordeons gefunden")
rows.append(
f"
| {html.escape(ts)} | "
diff --git a/backend-compliance/tests/test_b17_audit_walk.py b/backend-compliance/tests/test_b17_audit_walk.py
index c0b5cb44..89078cc7 100644
--- a/backend-compliance/tests/test_b17_audit_walk.py
+++ b/backend-compliance/tests/test_b17_audit_walk.py
@@ -26,6 +26,8 @@ _FAKE_WALK = {
"url": "https://example.com/datenschutz",
"anchor_text": "Datenschutz", "status": 200,
"title": "Datenschutzerklärung"},
+ {"timestamp": "2026-06-07T10:00:12+00:00",
+ "action": "expand_accordions", "expanded": 5, "max": 25},
],
"video": {
"filename": "video.webm",
@@ -49,14 +51,27 @@ class TestRender:
def test_action_table_lists_all_actions(self):
html = _render(_FAKE_WALK)
- # All four actions appear as
- assert html.count("
") >= 4 # incl. header
+ # All five actions + header appear as
+ assert html.count("
") >= 5
def test_nav_count_reflects_navigate_actions(self):
html = _render(_FAKE_WALK)
# 1 navigate in the fixture
assert "1 Compliance-Seiten" in html
+ def test_renders_accordion_expansion_count(self):
+ html = _render(_FAKE_WALK)
+ assert "5 Akkordeon" in html
+
+ def test_accordion_zero_renders_neutral_text(self):
+ walk = dict(_FAKE_WALK)
+ walk["actions"] = [
+ {"timestamp": "...", "action": "expand_accordions",
+ "expanded": 0, "max": 25},
+ ]
+ html = _render(walk)
+ assert "Keine Akkordeons gefunden" in html
+
class TestRunB17:
def test_no_request_skipped(self):
diff --git a/consent-tester/services/audit_walk_recorder.py b/consent-tester/services/audit_walk_recorder.py
index 739dff6a..a2ddf35c 100644
--- a/consent-tester/services/audit_walk_recorder.py
+++ b/consent-tester/services/audit_walk_recorder.py
@@ -142,8 +142,65 @@ async def _collect_footer_links(page) -> list[dict]:
return out
-async def _visit_link(page, link: dict, dwell_s: float = 5.0) -> dict:
- """Navigate to `link.href`, dwell, capture title + status."""
+async def _expand_accordions(page, max_expansions: int = 25) -> dict:
+ """Click through , [aria-expanded=false], summary, and
+ typical accordion-header patterns. Returns event dict with count.
+
+ Why: privacy policies and cookie tables often hide vendor/purpose
+ details behind accordions. A video that only scrolls the page
+ misses 60-80% of the auditable content. Expanding them in-place
+ captures the disclosed text in the recording.
+ """
+ started = _ts()
+ expanded = 0
+ selectors = (
+ "details:not([open]) > summary",
+ "[aria-expanded='false']",
+ "button.accordion-toggle",
+ "button[data-toggle='accordion']",
+ ".accordion-header button",
+ ".accordion-trigger",
+ "[class*=accordion] [class*=trigger]",
+ )
+ seen_handles: set[str] = set()
+ for sel in selectors:
+ try:
+ els = await page.query_selector_all(sel)
+ except Exception:
+ continue
+ for el in els:
+ if expanded >= max_expansions:
+ break
+ try:
+ # Dedup: get element-text as a poor-man's hash
+ txt = (await el.inner_text())[:60].strip()
+ if txt in seen_handles:
+ continue
+ seen_handles.add(txt)
+ # scroll-into-view + click; ignore obstructed clicks
+ try:
+ await el.scroll_into_view_if_needed(timeout=2000)
+ except Exception:
+ pass
+ await el.click(timeout=1500)
+ await page.wait_for_timeout(400)
+ expanded += 1
+ except Exception:
+ continue
+ if expanded >= max_expansions:
+ break
+ return {
+ "timestamp": started, "action": "expand_accordions",
+ "expanded": expanded, "max": max_expansions,
+ }
+
+
+async def _visit_link(
+ page, link: dict, dwell_s: float = 5.0,
+ expand_accordions: bool = True,
+) -> tuple[dict, dict | None]:
+ """Navigate to `link.href`, dwell, capture title + status, then
+ optionally expand all accordions in-place (Stage 2)."""
started = _ts()
start_t = time.monotonic()
status = 0
@@ -161,13 +218,23 @@ async def _visit_link(page, link: dict, dwell_s: float = 5.0) -> dict:
pass
except Exception as e:
err = str(e)[:200]
- return {
+ nav_event = {
"timestamp": started, "action": "navigate",
"url": link["href"], "anchor_text": link["text"],
"status": status, "title": title,
"dwell_s": round(time.monotonic() - start_t, 2),
"error": err or None,
}
+ expand_event = None
+ if expand_accordions and not err and status and status < 400:
+ try:
+ expand_event = await _expand_accordions(page)
+ # Give the camera a moment to record the expanded state
+ await page.wait_for_timeout(1500)
+ except Exception as e:
+ logger.info("expand_accordions failed for %s: %s",
+ link["href"][:60], e)
+ return nav_event, expand_event
async def record_audit_walk(
@@ -218,8 +285,12 @@ async def record_audit_walk(
})
for link in links[:max_links]:
- ev = await _visit_link(page, link, dwell_s=dwell_s)
- actions.append(ev)
+ nav_ev, expand_ev = await _visit_link(
+ page, link, dwell_s=dwell_s,
+ )
+ actions.append(nav_ev)
+ if expand_ev is not None:
+ actions.append(expand_ev)
await context.close()
await browser.close()