Skip to content

Add --viewport desktop/mobile/both with back-compatible scenario keys #11

Description

@LegalMarc

Goal

Add --viewport {desktop,mobile,both} and run the scenario set under each selected viewport. CMPs frequently ship a different banner, layer count and symmetry on mobile, and that is where most traffic is — it is the largest coverage gap in the tool. A default (desktop) run must remain structurally identical to today.

Dependencies

Scope

  • cookie-banner-auditor/scripts/lib/capture.py:
    • A ViewportProfile dataclass (label, width, height, user_agent, is_mobile, has_touch, device_scale_factor).
    • run_all_scenarios becomes a thin executor over checks.plan_scenarios(...), using dataclasses.replace(config, viewport=...) per profile.
    • The persistence entry keeps its wrapper. plan_scenarios treats persistence as a plain action=none entry, but the executor must still wrap that entry's raw scenario result in today's exact shape — {ran, banner_reprompted, banner_text, scenario_result, note} — and store it under the entry's key (persistence, persistence@mobile). Do not flatten it to a bare scenario result. is_scenario returns True iff "checkpoints" in result; the wrapper nests checkpoints under scenario_result, which is the only reason persistence stays out of scenario_validity_map and the row filters. Flattening would put a top-level checkpoints key on it and pull it into both — which is what the out-of-scope note below means by "your key scheme is wrong" — and would silently kill the E4 finding, since analysis.py reads persistence.get("ran").
    • context_options (~1519) must set is_mobile, has_touch and device_scale_factor from the profile, not just viewport.
    • Per-scenario directory/HAR names go through checks.scenario_dir_name.
    • The banner-crop clamp (~933) currently hardcodes min(width, 1440); derive it from the actual viewport.
  • cookie-banner-auditor/scripts/lib/analysis.py: split finding generation into a per-viewport pass; every literal "denial" / "baseline" / "gpc" becomes checks.scenario_key(base, vp), including the row filters and every depends_on_scenarios. Append the viewport to id_parts only when not desktop. Call checks.collapse_viewport_findings after partition_findings. Add a viewport column to the scenario-results table and label screenshots — only when more than one viewport ran.
  • cookie-banner-auditor/scripts/audit_site.py: the flag (default desktop); run_detect_only (~108) honours it; metadata["viewport"] becomes "1440x1000" for desktop-only or "1440x1000+390x844" for both.
  • cookie-banner-auditor/scripts/compare_runs.py: in _endpoints, skip requests whose phase is denial_verification_reload — the autosave probe's own reload traffic, landed by Wire verified-autosave denial path into execute_denial #7 (75b53da). That is the harness's traffic, not the site's, and counting it makes two runs look like they contacted different endpoints. Do not add a new phase; denial_verification_reload already exists in capture.py. Also make _comparability skip a field when both sides are None.
  • Tests in scripts/tests/smoke_test.py.

Out of scope

  • Bumping schema_version. No field is renamed or removed; a desktop-only run is byte-identical and a both run only adds keys (data, not schema).
  • Changing is_scenario, scenario_validity_map or partition_findings — with composite keys they need no change. If you find yourself editing them, your key scheme is wrong.
  • Running baseline repeats under mobile (keep them desktop-only).
  • run_fingerprint — it already hashes metadata["viewport"], so encoding the set into that existing string is what keeps fingerprints correct with no change.

Acceptance criteria

  • Lock test (the hard gate). A --viewport desktop run over the existing synthetic fixture produces: scenario-validity keys exactly ["baseline","denial","gpc"]; no finding id containing @ or ending -MOBILE; schema_version == "2.0"; and the exact set of top-level keys in audit-data.json equal to a hardcoded list. Assert the key set, not a subset — that is the byte-identical claim in executable form.
  • A two-viewport fixture yields both denial and denial@mobile in scenario_results and scenario_validity, and a mobile-only finding whose id ends -MOBILE.
  • Gating across viewports: with denial valid and denial@mobile invalid, the desktop F-POST-DENIAL-TRACKING is emitted while its mobile counterpart is suppressed, and overall status is INCOMPLETE. This is the load-bearing test — a mobile failure must never suppress a valid desktop finding, and must never let a mobile finding through.
  • Identical desktop/mobile findings collapse to one row citing both viewports; findings differing in severity or evidence strength stay separate.
  • The viewport column and mobile screenshot labels appear only when more than one viewport ran; a desktop-only report is unchanged.
  • --viewport mobile sets is_mobile/has_touch/a mobile UA on the context. Assert the context options, not merely the width — a narrow viewport with a desktop UA gets you a squashed desktop banner, not the mobile CMP, and that failure is invisible from width alone.
  • All existing tests pass unmodified.
  • bash scripts/check.sh passes.

Required verification

  • bash scripts/check.sh
  • .venv/bin/python -c "import sys; sys.path.insert(0,'cookie-banner-auditor/scripts'); import subprocess; h=subprocess.run(['.venv/bin/python','cookie-banner-auditor/scripts/audit_site.py','--help'],capture_output=True,text=True).stdout; assert '--viewport' in h, h[:400]; print('--viewport flag present')"
  • .venv/bin/python -c "import sys; sys.path.insert(0,'cookie-banner-auditor/scripts'); from lib import checks as c; assert c.scenario_key('denial','desktop')=='denial'; print('desktop keys still bare')"

Notes

  • This is the riskiest ticket in the queue. Its safety rests entirely on one invariant: scenario_key(base,"desktop") == base. Route every key construction through checks.scenario_key / parse_scenario_key; never write f"{base}@{vp}" inline. A surviving == "denial" comparison fails safe (that check silently becomes desktop-only) but fails quietly, which is why the lock test and the gating test both exist.
  • Finding ids must be distinct per viewport: compare_runs._findings_by_id is a dict comprehension, so two findings sharing an id silently drop one. _stable_id already normalises non-alphanumerics, so "denial@mobile" becomes -DENIAL-MOBILE on its own.
  • Collapse runs after partition so that merging a mobile observation into a desktop finding can never smuggle in evidence from an unverified interaction. Do not reorder these.
  • Runtime roughly doubles for both (~30 min on the thorough profile). Baseline repeats stay desktop-only, so mobile findings carry no stability caveat — note that in the report's limitations rather than leaving a reader to assume otherwise.
  • Mobile emulation is not a mobile device: no real touch hardware, no real network conditions, no in-app webview. Add that to the recorded limitations.
  • Update references/data-schema.md to document that scenario_results keys are base or base@viewport, since compare_runs.py now reads that contract.
  • Governing invariant throughout: never emit a finding from a scenario whose interaction did not complete and verify.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backlogDeferred; not queued for the loop

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions