feat(b17): Akkordeon-Expansion im Audit-Walk (Stufe 2, #7)
Nach jedem Compliance-Doc-Aufruf werden alle Akkordeons /
<details> / [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) <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,10 @@ def _render(walk: dict) -> str:
|
|||||||
detail = "Kein Accept-Button gefunden"
|
detail = "Kein Accept-Button gefunden"
|
||||||
elif act == "discover_footer_links":
|
elif act == "discover_footer_links":
|
||||||
detail = f"{a.get('count', 0)} Compliance-Links im Footer"
|
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(
|
rows.append(
|
||||||
f"<tr><td style='padding:4px 8px;font-family:monospace;"
|
f"<tr><td style='padding:4px 8px;font-family:monospace;"
|
||||||
f"color:#475569;'>{html.escape(ts)}</td>"
|
f"color:#475569;'>{html.escape(ts)}</td>"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ _FAKE_WALK = {
|
|||||||
"url": "https://example.com/datenschutz",
|
"url": "https://example.com/datenschutz",
|
||||||
"anchor_text": "Datenschutz", "status": 200,
|
"anchor_text": "Datenschutz", "status": 200,
|
||||||
"title": "Datenschutzerklärung"},
|
"title": "Datenschutzerklärung"},
|
||||||
|
{"timestamp": "2026-06-07T10:00:12+00:00",
|
||||||
|
"action": "expand_accordions", "expanded": 5, "max": 25},
|
||||||
],
|
],
|
||||||
"video": {
|
"video": {
|
||||||
"filename": "video.webm",
|
"filename": "video.webm",
|
||||||
@@ -49,14 +51,27 @@ class TestRender:
|
|||||||
|
|
||||||
def test_action_table_lists_all_actions(self):
|
def test_action_table_lists_all_actions(self):
|
||||||
html = _render(_FAKE_WALK)
|
html = _render(_FAKE_WALK)
|
||||||
# All four actions appear as <tr>
|
# All five actions + header appear as <tr>
|
||||||
assert html.count("<tr>") >= 4 # incl. header
|
assert html.count("<tr>") >= 5
|
||||||
|
|
||||||
def test_nav_count_reflects_navigate_actions(self):
|
def test_nav_count_reflects_navigate_actions(self):
|
||||||
html = _render(_FAKE_WALK)
|
html = _render(_FAKE_WALK)
|
||||||
# 1 navigate in the fixture
|
# 1 navigate in the fixture
|
||||||
assert "1 Compliance-Seiten" in html
|
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:
|
class TestRunB17:
|
||||||
def test_no_request_skipped(self):
|
def test_no_request_skipped(self):
|
||||||
|
|||||||
@@ -142,8 +142,65 @@ async def _collect_footer_links(page) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _visit_link(page, link: dict, dwell_s: float = 5.0) -> dict:
|
async def _expand_accordions(page, max_expansions: int = 25) -> dict:
|
||||||
"""Navigate to `link.href`, dwell, capture title + status."""
|
"""Click through <details>, [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()
|
started = _ts()
|
||||||
start_t = time.monotonic()
|
start_t = time.monotonic()
|
||||||
status = 0
|
status = 0
|
||||||
@@ -161,13 +218,23 @@ async def _visit_link(page, link: dict, dwell_s: float = 5.0) -> dict:
|
|||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e)[:200]
|
err = str(e)[:200]
|
||||||
return {
|
nav_event = {
|
||||||
"timestamp": started, "action": "navigate",
|
"timestamp": started, "action": "navigate",
|
||||||
"url": link["href"], "anchor_text": link["text"],
|
"url": link["href"], "anchor_text": link["text"],
|
||||||
"status": status, "title": title,
|
"status": status, "title": title,
|
||||||
"dwell_s": round(time.monotonic() - start_t, 2),
|
"dwell_s": round(time.monotonic() - start_t, 2),
|
||||||
"error": err or None,
|
"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(
|
async def record_audit_walk(
|
||||||
@@ -218,8 +285,12 @@ async def record_audit_walk(
|
|||||||
})
|
})
|
||||||
|
|
||||||
for link in links[:max_links]:
|
for link in links[:max_links]:
|
||||||
ev = await _visit_link(page, link, dwell_s=dwell_s)
|
nav_ev, expand_ev = await _visit_link(
|
||||||
actions.append(ev)
|
page, link, dwell_s=dwell_s,
|
||||||
|
)
|
||||||
|
actions.append(nav_ev)
|
||||||
|
if expand_ev is not None:
|
||||||
|
actions.append(expand_ev)
|
||||||
|
|
||||||
await context.close()
|
await context.close()
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user