From 80c477801785f69b23bce76486b8a4171de0487c Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 7 Jun 2026 17:23:55 +0200 Subject: [PATCH] feat(b17): Akkordeon-Expansion im Audit-Walk (Stufe 2, #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach jedem Compliance-Doc-Aufruf werden alle Akkordeons /
/ [aria-expanded=false] / Trigger-Patterns geklickt und im Video aufgenommen. - _expand_accordions(): 7 Selektor-Patterns, max 25 Expansionen pro Seite, Dedup nach inner_text (verhindert Endlos-Loops bei nesteten Strukturen). Scroll-into-view + click + 400ms warten sicher dass das Klick-Result im Video erfasst wird. - _visit_link(): Returns (nav_event, expand_event) Tuple. Expand läuft nur bei HTTP 2xx + ohne nav-error. - 1500ms post-expand wait gibt der Kamera Zeit, den finalen Zustand mitzuschneiden. Backend B17 render: "expand_accordions" Action wird als "5 Akkordeon/Details-Sektion(en) entfaltet" gerendert. Bei 0: "Keine Akkordeons gefunden" (neutraler Hinweis, kein Fehler). Real-World-Smoke gegen Elli: Impressum: 0 Akkordeons (keine) Datenschutzerkl: 5 Akkordeons aufgeklappt Nutzungsbeding: 0 Akkordeons Video-Größe verdoppelt sich (581 KB → 1.14 MB) — Reviewer sieht jetzt den vollen DSE-Vendor-Tabellen-Inhalt im Video. Tests: 10/10 grün (+2 für Akkordeon-Render-Pfade). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../compliance/api/agent_check/_b17_wiring.py | 4 + .../tests/test_b17_audit_walk.py | 19 ++++- .../services/audit_walk_recorder.py | 81 +++++++++++++++++-- 3 files changed, 97 insertions(+), 7 deletions(-) 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()