You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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_partsonly when not desktop. Call checks.collapse_viewport_findingsafterpartition_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.
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:ViewportProfiledataclass (label,width,height,user_agent,is_mobile,has_touch,device_scale_factor).run_all_scenariosbecomes a thin executor overchecks.plan_scenarios(...), usingdataclasses.replace(config, viewport=...)per profile.plan_scenariostreats persistence as a plainaction=noneentry, 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_scenarioreturns True iff"checkpoints" in result; the wrapper nests checkpoints underscenario_result, which is the only reason persistence stays out ofscenario_validity_mapand the row filters. Flattening would put a top-levelcheckpointskey 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, sinceanalysis.pyreadspersistence.get("ran").context_options(~1519) must setis_mobile,has_touchanddevice_scale_factorfrom the profile, not justviewport.checks.scenario_dir_name.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"becomeschecks.scenario_key(base, vp), including the row filters and everydepends_on_scenarios. Append the viewport toid_partsonly when not desktop. Callchecks.collapse_viewport_findingsafterpartition_findings. Add aviewportcolumn to the scenario-results table and label screenshots — only when more than one viewport ran.cookie-banner-auditor/scripts/audit_site.py: the flag (defaultdesktop);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 whosephaseisdenial_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_reloadalready exists incapture.py. Also make_comparabilityskip a field when both sides areNone.scripts/tests/smoke_test.py.Out of scope
schema_version. No field is renamed or removed; a desktop-only run is byte-identical and abothrun only adds keys (data, not schema).is_scenario,scenario_validity_maporpartition_findings— with composite keys they need no change. If you find yourself editing them, your key scheme is wrong.run_fingerprint— it already hashesmetadata["viewport"], so encoding the set into that existing string is what keeps fingerprints correct with no change.Acceptance criteria
--viewport desktoprun 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 inaudit-data.jsonequal to a hardcoded list. Assert the key set, not a subset — that is the byte-identical claim in executable form.denialanddenial@mobileinscenario_resultsandscenario_validity, and a mobile-only finding whose id ends-MOBILE.denialvalid anddenial@mobileinvalid, the desktopF-POST-DENIAL-TRACKINGis emitted while its mobile counterpart is suppressed, and overall status isINCOMPLETE. This is the load-bearing test — a mobile failure must never suppress a valid desktop finding, and must never let a mobile finding through.viewportcolumn and mobile screenshot labels appear only when more than one viewport ran; a desktop-only report is unchanged.--viewport mobilesetsis_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.bash scripts/check.shpasses.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
scenario_key(base,"desktop") == base. Route every key construction throughchecks.scenario_key/parse_scenario_key; never writef"{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.compare_runs._findings_by_idis a dict comprehension, so two findings sharing an id silently drop one._stable_idalready normalises non-alphanumerics, so"denial@mobile"becomes-DENIAL-MOBILEon its own.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.references/data-schema.mdto document thatscenario_resultskeys arebaseorbase@viewport, sincecompare_runs.pynow reads that contract.