Files
camoufox/pythonlib/tests/test_config_schema.py
T
Jake WriterandClaude Opus 5.5 a3dbe40d1a fix: stop generating a canvas seed, and drop config keys nothing reads
The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:22:28 -06:00

148 lines
5.5 KiB
Python

"""
Guard: every config key the browser reads must be declared in the schema.
Regression guard for the `media:spoof_codecs` gap in PR #562. The C++ side read
the key via MaskConfig::GetBool("media:spoof_codecs"), but nothing ever added it
to settings/properties.json, and validate_config() drops any key it does not
recognise -- printing "Skipping unknown patch media:spoof_codecs" and moving on.
The documented usage,
AsyncCamoufox(config={"media:spoof_codecs": True})
therefore did nothing at all: the key never reached the browser, so the feature
could not be switched on through the supported path.
This is the read-but-undeclared direction of a mistake the project has made
before in the other direction -- canvas:seed (#721) and navigator.maxTouchPoints
(#696) were both declared in the schema while nothing consumed them. A patch and
a schema entry are two halves of one change; this test fails the build when only
one half lands.
Run with:
cd pythonlib && python -m pytest tests/test_config_schema.py -v
"""
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
PROPERTIES = REPO / "settings" / "properties.json"
# MaskConfig::GetBool("k") / GetString("k") / GetUint32("k") / HasKey("k") ...
MASKCONFIG_READ = re.compile(r'MaskConfig::(?:Get|Has)\w*\(\s*"([^"]+)"')
# Keys read through a variable or built at runtime rather than a string literal.
# Add here (with a reason) only when the read genuinely cannot name its key.
ALLOWED_UNDECLARED: set = set()
# Declared keys the browser never reads, each with the reason it is declared
# anyway. Everything else in properties.json must be read by a patch or by
# Juggler: a key nothing reads does nothing, silently.
NOT_READ_BY_THE_BROWSER = {
"locale:script": "the launcher joins it with locale:language/region into the UI locale",
"navigator.doNotTrack": "the launcher applies it as privacy.donottrackheader.enabled (#760)",
"navigator.buildID": "declared ahead of the patch that reads it (#780)",
}
# How Juggler and the patches name a key: a quoted string literal.
QUOTED = '"{key}"', "'{key}'"
def _sources():
for pattern in ("patches/**/*.patch", "additions/**/*"):
for path in REPO.glob(pattern):
if path.is_file():
yield path
def _declared_keys() -> set:
return {entry["property"] for entry in json.loads(PROPERTIES.read_text())}
def _keys_read() -> dict:
"""Map config key -> sorted list of files that read it."""
found: dict = {}
for path in _sources():
try:
text = path.read_text(errors="ignore")
except OSError:
continue
for match in MASKCONFIG_READ.finditer(text):
found.setdefault(match.group(1), set()).add(
str(path.relative_to(REPO))
)
return {k: sorted(v) for k, v in found.items()}
def test_properties_json_is_wellformed():
entries = json.loads(PROPERTIES.read_text())
assert entries, "settings/properties.json is empty"
for entry in entries:
assert "property" in entry and "type" in entry, f"malformed entry: {entry}"
def test_every_key_the_browser_reads_is_declared():
declared = _declared_keys()
read = _keys_read()
assert read, "found no MaskConfig reads -- the scanner regexp has gone stale"
undeclared = {
key: files
for key, files in read.items()
if key not in declared and key not in ALLOWED_UNDECLARED
}
if undeclared:
lines = [
"config keys are read by the browser but missing from "
"settings/properties.json,",
"so validate_config() silently drops them and the feature cannot be "
"enabled through the Python API:",
"",
]
for key, files in sorted(undeclared.items()):
lines.append(f" {key}")
for f in files:
lines.append(f" read in {f}")
pytest.fail("\n".join(lines))
def test_every_declared_key_is_read_by_the_browser():
"""The other direction: canvas:seed (#721) was declared, generated by both
launchers and sent on every launch for three releases after the patch that
read it was removed (#528)."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
unread = sorted(
key
for key in _declared_keys()
if key not in NOT_READ_BY_THE_BROWSER
and not any(form.format(key=key) in text for form in QUOTED)
)
assert not unread, (
"settings/properties.json declares keys that no patch or Juggler file "
f"reads, so setting them does nothing: {unread}. Remove them, or add them "
"to NOT_READ_BY_THE_BROWSER with the reason they are declared."
)
def test_keys_not_read_by_the_browser_are_really_unread():
"""An exemption must lapse once the browser starts reading the key."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
now_read = [
key for key in NOT_READ_BY_THE_BROWSER
if any(form.format(key=key) in text for form in QUOTED)
]
assert not now_read, f"remove from NOT_READ_BY_THE_BROWSER, the browser reads them now: {now_read}"
@pytest.mark.parametrize("key", ["media:spoof_codecs"])
def test_known_previously_missing_keys_stay_declared(key):
"""Pin the specific keys this guard was written for, so a schema edit that
drops one fails loudly here rather than only in the general scan above."""
assert key in _declared_keys(), (
f"{key} is read by the browser but is not declared in "
f"settings/properties.json -- see PR #562"
)