Files
camoufox/pythonlib/tests/test_voices.py
T
Jake WriterandClaude Opus 5 3f0f850bf3 fix(pythonlib): identity draws that match real machines and stay stable
Launcher-side fixes found by comparing camoufox against stock Firefox 152.0.4
on Linux, Windows 11 and macOS hosts:

- DNT / GPC: BrowserForge draws doNotTrack "1" on most Firefox samples, but a
  stock Firefox 152 reports "unspecified" and globalPrivacyControl false; the
  stock defaults are used unless the caller sets them, and both are applied as
  prefs so the API, the worker and the DNT / Sec-GPC headers agree (#760).
- Timezone and geolocation: the timezone is passed to the browser, and a
  configured position sets permissions.default.geo so permissions.query agrees
  with the auto-grant (#769, #773).
- hardwareConcurrency: the reported count is the fingerprint's and the browser
  is pinned to that many cores (cpu_affinity.py, Linux/Windows), so worker
  timing agrees with it; otherwise the host count snapped into the core counts
  real machines ship with (never 2, Firefox's resistFingerprinting value).
- Fonts: the OS base is always present in full, OS-version variants are drawn
  all-or-nothing, co-shipped groups stay together, Cascadia is never claimed
  off Windows, a native macOS/Windows identity claims only the real OS base,
  and gfx.font_rendering.fallback.async is off on Linux so per-character
  fallback does not depend on cmap-load timing.
- Speech voices: a per-OS installed-voice model (voice-manifests.json) with
  the voiceURI formats each backend really produces (voice-uris.json); no
  default voice where stock has none.
- WebGL: extensions a release Firefox never exposes are filtered, but
  OVR_multiview2 stays for Windows D3D11 renderers, which expose it.
- Media devices: a seeded draw of common per-OS devices with OS-style labels.
- Windows scrollbars follow the drawn Windows version (overlay on 11).
- Glyph-advance perturbation (fonts:spacing_seed) defaults to off: it moved
  every measureText width off the value the same font gives on a real machine.
- Launcher prefs are also exported as CAMOU_PREFS_1..N so camoufox.cfg applies
  them at startup, and the browser UI locale follows the spoofed locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:09:40 -06:00

196 lines
6.6 KiB
Python

"""
Tests for camoufox.fingerprints speech-voice generation.
Mirrors camoufox-js/tests-camoufox-js/voices.test.ts.
Run with:
cd pythonlib && python -m pytest tests/test_voices.py -v
The core regression these guard: every spoofable OS -- including Linux --
must yield a non-empty list of MaskConfig voice OBJECTS (not raw
"Name:lang:type" strings), or the C++ MaskConfig::MVoices() silently drops
them and the host machine's native voices leak through.
"""
import os
import sys
import pytest
# Make `import camoufox` resolve to the in-tree pythonlib without an install.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox.fingerprints import ( # noqa: E402
_generate_random_voice_subset,
_normalize_preset_voices,
)
_REQUIRED_FIELDS = {"lang", "name", "voiceUri", "isDefault", "isLocalService"}
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_non_empty_for_every_os(target_os):
voices = _generate_random_voice_subset(target_os, "en-US")
assert len(voices) > 0
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_entries_are_full_objects(target_os):
# MaskConfig::MVoices() drops any entry missing a field, so every voice
# must carry the full object shape.
for v in _generate_random_voice_subset(target_os, "en-US"):
assert isinstance(v, dict)
assert _REQUIRED_FIELDS <= set(v.keys())
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_no_default_voice(target_os):
# Stock Firefox 152 marks no SpeechSynthesisVoice.default on any OS.
voices = _generate_random_voice_subset(target_os, "en-US")
assert voices and not any(v["isDefault"] for v in voices)
def test_windows_display_language_pack():
# A German display language gets the German OneCore pack first; en-US is
# the pack every stock en-US box reports (David / Mark / Zira + Desktop).
de = _generate_random_voice_subset("windows", "de-DE", seed=1)
assert de[0]["lang"] == "de-DE"
en = _generate_random_voice_subset("windows", "en-US", seed=1)
assert [v["name"] for v in en[:3]] == [
"Microsoft David - English (United States)",
"Microsoft Mark - English (United States)",
"Microsoft Zira - English (United States)",
]
class TestLinuxSpeechdUris:
"""Linux voiceUris must match Firefox's SpeechDispatcherService.cpp:
urn:moz-tts:speechd:<NS_EscapeURL(name, OnlyNonASCII|Spaces)>?<lang>
"""
def setup_method(self):
self.lin = _generate_random_voice_subset("linux", "en-US")
def test_prefix_and_lang_suffix(self):
for v in self.lin:
assert v["voiceUri"].startswith("urn:moz-tts:speechd:")
assert v["voiceUri"].endswith("?" + v["lang"])
def test_spaces_escaped_punctuation_intact(self):
gb = next(v for v in self.lin if v["name"] == "English (Great Britain)")
assert gb["voiceUri"] == "urn:moz-tts:speechd:English%20(Great%20Britain)?en-GB"
def test_all_local_service(self):
assert all(v["isLocalService"] for v in self.lin)
def test_normalize_preset_voices_converts_strings():
# Presets historically store "Name:lang:type" strings.
out = _normalize_preset_voices(
["Albert:en-US:local", "Alice:it-IT:local"], "macos"
)
assert all(_REQUIRED_FIELDS <= set(v.keys()) for v in out)
assert out[0]["name"] == "Albert"
assert out[0]["lang"] == "en-US"
assert sum(1 for v in out if v["isDefault"]) == 1
def test_normalize_preset_voices_passes_through_objects():
obj = {
"name": "Alex",
"lang": "en-US",
"voiceUri": "urn:moz-tts:osx:alex",
"isDefault": True,
"isLocalService": True,
}
out = _normalize_preset_voices([obj], "macos")
assert out == [obj]
def test_unknown_os_falls_back_to_macos():
assert len(_generate_random_voice_subset("plan9", "en-US")) > 0
# ── Fail-closed voice configuration (daijro/camoufox#731) ────────────────────
#
# nsSynthVoiceRegistry only withholds the host's speech-dispatcher / SAPI /
# NSSpeech voices while Camoufox owns the list. An empty or unset `voices`
# used to fall through to the host backend and register every native voice --
# 14805 espeak-ng entries on a stock Linux box -- under a fingerprint claiming
# macOS or Windows.
from camoufox.exceptions import InvalidPropertyType # noqa: E402
from camoufox.utils import validate_voices # noqa: E402
def _launch_config(**kwargs):
"""Rebuild the config dict from the chunked CAMOU_CONFIG_* env vars."""
import json
from camoufox.utils import launch_options
opts = launch_options(headless=True, i_know_what_im_doing=True, **kwargs)
env = opts["env"]
chunks = sorted(
(k for k in env if k.startswith("CAMOU_CONFIG_")),
key=lambda k: int(k.rsplit("_", 1)[1]),
)
return json.loads("".join(env[k] for k in chunks))
def test_block_flag_is_pinned_by_default():
# Without this the browser has no instruction to withhold host voices when
# the list it receives turns out to be empty or unusable.
assert _launch_config(os="macos")["voices:blockIfNotDefined"] is True
def test_caller_can_override_the_block_flag():
cfg = _launch_config(os="macos", config={"voices:blockIfNotDefined": False})
assert cfg["voices:blockIfNotDefined"] is False
def test_voice_generation_failure_fails_closed(monkeypatch):
import camoufox.utils as utils
def boom(*_args, **_kwargs):
raise RuntimeError("voices.json unreadable")
monkeypatch.setattr(utils, "_generate_random_voice_subset", boom)
cfg = _launch_config(os="macos")
# An empty list plus the block flag means "no voices" -- never "all of the
# host's".
assert cfg["voices"] == []
assert cfg["voices:blockIfNotDefined"] is True
@pytest.mark.parametrize(
"value",
[
["Alex:en-US:local"], # the bare-string shape MVoices() drops
[{"name": "Alex", "lang": "en-US"}], # incomplete object
"Alex", # not a list at all
],
)
def test_validate_voices_rejects_unusable_shapes(value):
with pytest.raises(InvalidPropertyType):
validate_voices(value)
@pytest.mark.parametrize(
"value",
[
[], # empty is legal: it means "no voices", and the flag enforces it
[
{
"lang": "en-US",
"name": "Alex",
"voiceUri": "urn:moz-tts:osx:alex",
"isDefault": True,
"isLocalService": True,
}
],
],
)
def test_validate_voices_accepts_usable_shapes(value):
validate_voices(value)