Raising CONSTRAINTS.MIN_VERSION is how this library has always forced a browser
upgrade (beta.12 -> beta.15 -> beta.17 -> beta.18 -> beta.19); the floor only
became 'alpha.1' incidentally, in an unrelated PR. That left the branch dead,
and it had rotted: camoufox_path() probed INSTALL_DIR/version.json, which only
the pre-multiversion flat layout ever wrote. With a versioned install below the
floor it raised FileNotFoundError instead of falling through to a fetch, so
raising the floor would have crashed every existing user rather than upgrading
them.
Treat a missing root version.json as "no legacy install here" so the caller
falls through to CamoufoxFetcher().install() as intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite in tests/async is upstream Playwright's conformance suite, so it
asserts upstream semantics: tests read globals their own page scripts defined
and pass element handles into evaluate(). Under Camoufox's isolated world about
59 of them fail on "X is not defined" for a global the page really did set.
The "mw:" prefix cannot stand in -- it refuses handles by design (Runtime.js)
and much of this suite needs them -- so this adds a disableWorldIsolation config
key that makes the default world the page's own, and turns it on for this suite
only.
The flag gives up the property this fork exists for: automation JS becomes
visible to the page again. It is a conformance-suite mode, not a scraping mode.
Camoufox's isolation keeps its own coverage in tests/patches/isolated-evaluate.py,
which must go on passing without the flag.
Measured on beta.30 with Playwright 1.62: 73 failed/1023 passed -> 14
failed/1082 passed, with no test failing that was not already failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since the isolated-world change (#707), add_init_script() only ever patched the
isolated world, so a script installed to change what a *site* observes reached
nothing. It failed silently three ways over: page.evaluate() read the value back
from the same isolated world and reported success; "mw:" was accepted without
complaint, because Playwright's InitScript constructor wraps every body in
`(() => { ... })();` and the prefix became a label statement inside it; and
evaluateScriptSafely() routes even a throwing script into dump(). Two profiles
with different noise seeds handed a site an identical canvas, and nothing
downstream could tell.
Route init scripts that ask for the main world to the main-world twin, matching
the prefix both wrapped (as Playwright sends it) and bare. Refusals are loud:
for a spoofing tool, silently not spoofing is the worst available failure.
Resolve the main world's global through a getter rather than capturing it in
_createIsolatedContext(). Init scripts run from _onGlobalObjectCleared(), and
the global reachable there is not always the one the document settles on, which
made the original report's own fix land in a global nothing read 2 times in 8.
Measured 8/8 launches after this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cherry-picked #742 replaced `playwright = "<1.61"` with `"*"`. The
breakage it fixes is real, but an unbounded range removes the tripwire
rather than making the browser forward-compatible: `camoufox.server`
imports `playwright._impl._driver.compute_driver_executable`, a private
API with no compat guarantee, and `additions/juggler` is a fork of one
Playwright vintage that every minor release is free to break again --
1.61 sending `viewport.isMobile` is exactly that, and it will recur.
So the cap moves up rather than away, to the tested-current 1.62.0. No
lower bound is added: this package has never carried one, and the launch
path was exercised against 1.53.0 as well as the 1.62.0 the Playwright
suite runs on.
build-tester/requirements.txt mirrors this pin by its own comment, so it
moves with it.
- Remove package pin to allow Playwright >=1.61.
- Adds WebSocket frame timestamps to Juggler events.
- Extends viewport and setViewportSize protocol data with screenSize,
isMobile, and deviceScaleFactor.
- Adds WebP screenshot support, including a default quality of 100.
- Updates the protocol schemas to describe the new fields and screenshot
format.
Playwright 1.61 sends viewport.isMobile in Browser.setDefaultViewport, which
the juggler's protocol schema rejected -- every context creation failed, so
the whole Playwright suite errored out at fixture setup rather than reporting
results. This fixes the schema rather than capping the version.
Cherry-picked from daijro/camoufox#742 (closes#653).
Co-authored-by: LamerLink <36551116+LamerLink@users.noreply.github.com>
BrowserForge picks navigator/screen; the GPU is drawn separately from
webgl_data.db weighted only by OS. Nothing ties the two together, so the
synthetic path emits pairs no real machine ships -- a discrete desktop GPU
behind a 1024x600 panel. Consistency checks (Pixelscan, Fingerprint.com) read
that as masking even though every individual value is plausible on its own.
Builds on @dyiapanis's #730, which identified the problem and the GPU-class
thresholds, with three changes:
* Constrain the GPU to the screen rather than the screen to the GPU.
sample_webgl_for_screen does rejection sampling, so the GPU keeps
webgl_data.db's real OS-weighted distribution and the geometry -- already
reconciled against the real display and the window box by
clamp_screen_to_display / fix_screen_no_taskbar / clamp_window_dimensions
/ clamp_window_position -- is left alone.
* Where no coherent GPU exists at all (BrowserForge still carries
netbook-era geometry, and nothing in the pool drives a sub-1366x768
panel), raise_screen_to_gpu_floor lifts the screen instead. It measures
the screen-to-avail gap BEFORE mutating -- #730 computed it after
overwriting screen.height, which turned a 1024x600 -> 1080 bump into a
520px "taskbar", a fresh impossible-geometry tell -- and it runs BEFORE
clamp_screen_to_display so a genuinely small monitor still wins and a
headful window cannot be pushed back off its own display (#499).
* No Apple-M Retina floor. Apple silicon also ships in the Mac mini and Mac
Studio, which drive whatever external monitor is attached, so pinning it
to 2560x1600 would reject real hardware and shrink the pool for nothing.
Measured over 300 synthetic fingerprints, incoherent GPU/screen pairs fall
from 54.3% to 0%, with avail <= screen and availHeight < height holding in
every trial. The screen floor is a no-op for the Linux and Windows pools
(0/400 draws below it) and fires on 3.5% of macOS draws, so the entropy cost
is confined to the implausible tail it exists to remove.
Co-authored-by: D Yiapanis <d@yiapanis.co>
Firefox registers the host's speech-dispatcher / SAPI / NSSpeech voices
unless something stops it, and nsSynthVoiceRegistry only stopped it when the
explicit `voices:blockIfNotDefined` flag was set. Nothing set that flag, so
the host was suppressed only as a side effect of a non-empty spoofed list --
and the Python layer built that list inside a bare `except Exception: pass`.
Any path that left the list empty or unset therefore fell through to the host
backend. On a stock Linux box that exposes 14805 espeak-ng voices to the page
under a fingerprint claiming macOS or Windows, which both leaks the real host
OS and contradicts the rest of the profile. Reproduced on 152.0.4-beta.29:
config voices exposed
generation raises 14805 (all host speechd)
{"voices": []} 14805 (all host speechd)
valid list 115 (correct)
Three changes, so the failure is closed at both layers:
* nsSynthVoiceRegistry::AddVoice now also blocks when MaskConfig carries a
`voices` array at all -- including an empty one, or one whose entries were
all rejected as malformed. An empty spoofed list must mean "no voices",
never "all of the host's". With no `voices` key the browser still behaves
like stock Firefox, so a bare binary is unaffected.
* launch_options pins `voices:blockIfNotDefined` (via set_into, so an
explicit caller value still wins) and degrades a generation failure to an
empty list rather than leaving the key unset. It also passes the spoofed
navigator.language through, so the default voice matches the locale.
* validate_voices rejects the shapes MaskConfig::MVoices() silently drops --
bare "Name:lang:type" strings and half-filled objects -- before launch
instead of letting them degrade into a host-voice leak.
Both failure paths now expose 0 voices; the normal path still exposes 115.
The font-spacing randomisation in anti-font-fingerprinting.patch fed a
running total into each glyph's x_offset/y_offset as well as its advance:
glyphPositions[i].x_advance += spacing;
glyphPositions[i].x_offset += cumulativeOffset;
cumulativeOffset += spacing;
x_offset is the GPOS positioning offset -- the thing that parks a combining
mark over its base. It does not contribute to the measured width, so it adds
nothing to the metric the randomisation exists to perturb, but layout never
sees it either: gfxTextRun stores advances, while painting honours the
offsets. The running total therefore paints every glyph a further `spacing`
past where layout placed it, and detaches every combining mark from its base.
Latin hides this because Gecko's word cache shapes each space-delimited word
separately, so the accumulator resets constantly. Thai has no inter-word
spaces, so a whole paragraph is a single shaping run and the error grows
without bound. Measured on 152.0.4-beta.29 at the worst-case seed, painted
text overran its layout box by:
Thai 1x run 4.90px Latin 1x run 0.00px
Thai 2x run 9.80px Latin 2x run 0.00px
Thai 4x run 20.60px Latin 4x run 0.00px
which is the garbled Thai in #741 -- and, since the seed is randomised per
launch, why it looks like an intermittent regression rather than a bug.
Perturb advances only, and skip zero-advance glyphs: a combining mark is not
a separate character, and widening it pushes the following base away and
strands the mark. Every advancing glyph still gets +spacing, so the measured
width -- the actual fingerprinting signal -- is perturbed exactly as before
(verified: 4/8/16/32px by run length, unchanged).
After the fix the overrun is 0.00px for Thai at every run length.
PR #707 changed the browser binary: two new C++ patches
(debugger-invisible-to-content, trusted-automation-events), an extended
screen-spoofing patch, eight juggler files packed into omni.ja, the
bundled settings, and two mozconfig flags. None of that reaches users
until a rebuild, so the artifacts need a version of their own rather
than reusing beta.28, which is already published from 0583c3e.
Artifact names come from this file (Makefile does `include upstream.sh`
+ `export`); nothing in the build reads the git tag. So this has to land
before v152.0.4-beta.29 is tagged, or the release carries beta.28 files.
Conflict: pythonlib/camoufox/utils.py — both sides fixed the fontconfig
cache dir independently (#654 here, #712 upstream). The two spellings
resolve to the same path, since pkgman's INSTALL_DIR is
platformdirs.user_cache_dir("camoufox"). Kept INSTALL_DIR so the module
has one name for that directory, dropped the now-unused platformdirs
import, and kept the comment explaining why the dir must sit outside the
read-only browser bundle.
_generate_fontconfig hardcoded ~/.cache/camoufox/fontconfig instead
of respecting XDG_CACHE_HOME. On systems where ~/.cache is read-only
(e.g. containerized environments), this causes OSError on browser launch.
Replaced os.path.join(os.path.expanduser('~'), '.cache', 'camoufox',
'fontconfig') with os.path.join(platformdirs.user_cache_dir('camoufox'),
'fontconfig'). platformdirs is already a declared dependency and
respects XDG_CACHE_HOME on Linux, ~/Library/Caches on macOS, and
%LOCALAPPDATA% on Windows.
Closes#654
camoufox ships 287 font files under Contents/Resources/fonts on macOS
(Makefile passes `--fonts windows linux` to package.py) and already sets
`defaultPref("gfx.bundled-fonts.activate", 1)` in settings/camoufox.cfg,
but none of them are ever loaded: the macOS build does not define
MOZ_BUNDLED_FONTS, so CoreTextFontList::ActivateBundledFonts() is
compiled out. That function is the only caller of ActivateFontsFromDir(),
which is what registers <GRE>/fonts with CoreText via
CTFontManagerRegisterFontURLs().
Upstream defaults the option to `target.os == "WINNT" or target.kernel ==
"Linux"` (toolkit/moz.configure), so Linux and Windows enable it
implicitly and macOS silently does not. The configure option itself is
gated only on `project == "browser"`, which camoufox is; there is no
platform restriction.
Effect on a macOS host: a spoofed font list currently resolves against
the host's own fonts, so the measurable set is `host fonts INTERSECT
claimed list` -- 13 of 60 claimed families measurable in local testing,
and those 13 are exactly the macOS-native ones. Font metrics are a
cross-checked fingerprinting surface (a UA claiming Windows alongside
macOS font metrics is a contradiction detectors look for), so this
undercuts the spoofing rather than merely reducing it.
Refs #706
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the default half of 4b20b77. That commit raised Xvfb's root window
from 1x1x24 to 1920x1080x24 for #458, on the reasoning that a 1x1 root
"breaks anything that measures the screen".
That reasoning does not hold here:
- screen.* never comes from the root window. It comes from the generated
fingerprint, applied per context in the browser, and
clamp_screen_to_display() is skipped outright for virtual displays (the
`not virtual_display` guard in utils.py), so a 1x1 root cannot clamp a
generated screen down to 1x1.
- #458's actual symptom -- blank/dark screenshots -- does not reproduce on
152.0.4-beta.28. Measured at both geometries on the same build, same page:
Xvfb 1x1x24 493 distinct colours, 57.5% dominant -> renders
Xvfb 1920x1080x24 493 distinct colours, 55.8% dominant -> renders
Identical. Firefox composites offscreen, so the root window size does not
gate rendering. A full-page screenshot of example.com under 1x1x24 is
pixel-correct.
1x1x24 is Camoufox's long-standing default and has run that way for years.
CAMOUFOX_VIRTUAL_DISPLAY_SIZE is kept as an escape hatch for anyone who does
want a real framebuffer, and still validates its input.
The Composite half of 4b20b77 was already reverted separately in 75d09a3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checks ran as a <script> in the served page and handed their results back
through window.__testResults__. That stopped working the moment page.evaluate()
moved into an isolated world: the runner reads those globals via evaluate() and
wait_for_function(), which no longer see anything the page itself wrote. Every
profile failed before a single check executed -- per-context ones with
'NoneType' object has no attribute 'get', global ones with a 120s
wait_for_function timeout.
Running the bundle as an init script puts it in the same world the runner reads
from. Verified first that this does not change what is measured: platform,
oscpu, userAgent, hardwareConcurrency, language, screen, devicePixelRatio,
timezone, WebGL vendor/renderer and fonts.check all read identically from the
isolated world and from page script, because the spoofing is at the C++ level
and Xrays show the same values.
It is also the better shape for an antibot tester -- the checks are now
invisible to the page under test. Init scripts run at document-start, hence the
readiness wait the inline version did not need.
Result on a local x86_64 Linux build: 1023/1048 checks, grade A across all 8
profiles. The remaining failures are all screen.width/height on per-context
profiles, where the runner clamps its viewport request to 1920x1080 while the
preset expects a larger screen.
Also pin playwright<1.61: it sends viewport.isMobile in
Browser.setDefaultViewport, which this juggler's protocol schema rejects, and
requirements.txt was unpinned. Matches the cap in pythonlib/pyproject.toml.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mach build` leaves dist/bin/fonts holding only TwemojiMozilla.ttf -- the font
bundles and fontconfig are staged by scripts/package.py, so they exist only in
packaged builds. Anything launching the objdir binary through the Python
wrapper therefore starts a browser with no usable content font, because the
wrapper sets FONTCONFIG_FILE to a file that is not there.
It fails confusingly: the browser chrome still has system fonts, so the only
symptom is tofu boxes in page content, and through AsyncCamoufox it surfaces as
a TargetClosedError with no indication of the cause. That cost real time while
writing the tests/patches scripts, hence the note in their docstrings.
`make stage-fonts` copies them in. Idempotent, and a no-op when nothing is
built yet. Not needed by `make run` or `make tests`, which launch the binary
directly and fall back to the system fontconfig.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most Playwright actions already reach the page through the widget layer, so
they are real by construction. The ones that cannot -- selecting an <option>,
filling a date or colour input, setting files on a file input -- are performed
by mutating the DOM and dispatching the events the widget code would have sent.
Those come out isTrusted: false, and they also skip the user-interacted flag
that :user-valid selects on, so a page can separate every one of them from a
real user's.
Event::Init now treats a caller in one of juggler's automation worlds as
trusted. The check is narrow on purpose: an ExpandedPrincipal whose allowlist
is exactly the target document's principal, which excludes WebExtension content
scripts (expanded principals that also list the extension). It tests the
scripted caller rather than the current realm, because the sandbox reaches
Event through an Xray and by that point the wrapper has entered the page's
realm.
A mouse-driven <select> commit also dispatches events on the chosen <option>
from the parent process; Playwright mutates the DOM instead, so those were
simply missing. nsINode.cpp now synthesises them in the shape
SelectChild.sys.mjs produces.
PageAgent's file-picker events were marked cancelable and composed, which the
real picker is not -- corrected to match DispatchEvents() in
HTMLInputElement.cpp.
tests/patches/trusted-events.py measures against a genuine widget-driven
selection rather than a hard-coded expectation: it focuses a <select>, presses
ArrowDown to go through HTMLSelectElement::UserFinishedInteracting, and
requires select_option's events to match event for event, target for target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inside an isolated world, `window` resolved through sandboxPrototype to the
page's window, while top-level declarations and exported bindings landed on the
sandbox global. The two were different objects, so `window.x = 1` and
`globalThis.x = 1` wrote to different places and only the latter read back.
Playwright calls its own bindings as `globalThis[name]` and survives, but
anything reaching for `window[name]` does not: expose_function() as documented,
and Playwright's clock, which installs stubs in one and looks them up in the
other. Aliasing the two repairs 21 tests (the whole page_clock block, locator
focus/blur, expose_binding cycles).
Nothing is lost -- the sandbox still inherits every real window property
through its prototype, so window.document, window.location, getComputedStyle,
matchMedia and addEventListener all resolve and invoke correctly. It is also
the more faithful shape: `window === globalThis` holds in a real page and did
not hold here.
One deviation remains, visible only to the automation: `window ===
document.defaultView` is false inside the world, and Playwright's serializer no
longer labels the world's global "ref: <Window>". Neither is reachable from
page script -- verified that a page-side probe still sees window identical to
document.defaultView, globalThis, top, self and frames, that no sandbox-named
property appears on window, and that writes through window, document
defaultView, top, ownerDocument.defaultView and indirect eval all stay
invisible to the page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Playwright routes page.evaluate() to the execution context juggler names '',
and upstream puts that context on the page window itself. Everything the
automation evaluates is then reachable by page script: a detection script can
hook Function.prototype.toString, window.eval or Object.defineProperty and
watch the automation work. That is the leak this fork exists to avoid, and it
regressed silently in 03c1230 ("migrate Juggler modules from JSM to ESM"),
which replaced the juggler sources with upstream's -- a two-line change,
invisible in a diff full of module-format churn, and no test noticed, because
page.evaluate() keeps working either way. It just stops being hidden.
FrameTree.js gives the '' world a Cu.Sandbox over the page window instead, so
the automation runs in its own compartment. tests/patches/isolated-evaluate.py
pins the property so it cannot regress the same way twice.
The cost is that Xray vision hides the page's own JS state, so
page.evaluate('window.pageVar') reads undefined. Runtime.js therefore carries a
`mw:` escape hatch: a standalone re-implementation of Playwright's
utilityScript.evaluate compiled inside the page's real global, gated on the
`allowMainWorld` config key and off by default. It mirrors the wire format
exactly, since the client would otherwise misread a returned object such as
{a: 1} as a serialized array. Handles are refused rather than silently
mistranslated.
forceScopeAccess now selects a system-principal sandbox for that world rather
than installing an accessor on the page's Element.prototype (#628), so the flag
no longer advertises itself to anything that probes for shadowRootUnl.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attaching Juggler's Debugger marks the realm a debuggee, and SpiderMonkey
changes content-visible behaviour for debuggee realms: promise return values
are treated as implicitly used, throw sites capture stacks unconditionally,
and async stack capture switches on. A page can therefore tell that something
is attached, and the shape it sees is the one DevTools produces -- an
automated browser that looks like it has DevTools open all the time.
Firefox already has the concept this needs (invisibleToContent) but only
consults it in a few places, so the patch threads it through: a realm is
"debuggee visible to content" only when it has at least one debugger that is
*not* invisibleToContent, and the content-visible switches consult that
instead of isDebuggee().
Juggler then sets invisibleToContent on both debuggers it creates -- the
content one in Runtime.js and the per-worker one in WorkerMain.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Triaged all 76 failures from the full run. None was a Camoufox browser bug --
every one was the vendored harness disagreeing with the Playwright it runs
against, or asserting a response no real server sends. Each was checked against
stock Firefox before being written off.
Harness bugs that hid real coverage:
* conftest's RemoteServer wrote snake_case launch options into a JSON consumed
by the Node driver, which needs camelCase, so `executable_path` was dropped
and launch-server fell back to a Firefox that isn't installed. It printed no
endpoint and all 16 connect tests died on an empty ws_endpoint with a
nonsense "Port should be >= 0 and < 65536. Received type string ('')".
Also drop None-valued options: a null `channel` aborts the driver outright.
16 failed -> 17 passed.
* tests/server.py answered 404/401 with a bare status line -- no Content-Type,
no body. Gecko renders that through the plaintext viewer and then never
fires `load`, leaving readyState at "interactive" forever, so page.goto()
(which waits for `load`) hung for the full timeout. That single defect
accounted for 17 of the 76 failures across five files, and cost 30s each.
Confirmed on stock Firefox too, so it is Gecko behaviour, not ours -- real
servers always send a body. clearcookies alone: 5 failed in 152s -> 7 passed
in 2s.
Removed APIs (gone from every Playwright the package supports, <1.61):
* test_accessibility.py in full -- Page.accessibility no longer exists.
* the two expose_binding(handle=True) tests -- the parameter is gone.
* test_glob_to_regex plus its import shim -- it pinned the old `?`/`[]` glob
wildcards, which upstream deliberately made literals. It only ever exercised
Playwright's private helper, never Camoufox.
Assertion drift, updated to what the current Playwright actually does:
* expect(...) failures raise AssertionError, not playwright.Error.
* editability is undefined for a <button>; use a readonly input.
* timeout wording: 'Expect "x" with timeout Nms', 'Timeout Nms exceeded'.
* traces no longer carry the Python-level `apiName`; action events record
protocol-level class+method ("Frame.goto"). Reading the old key raised
KeyError. 5 failed -> 11 passed.
* APIRequestContext `params` are appended to an existing query rather than
replacing it -- assert the request is built correctly instead.
* test_network asserted "Firefox" in the UA. The bare binary advertises
"Camoufox/<version>"; the Python package rewrites it to "Firefox/<version>"
(verified on the wire and in navigator.userAgent). This suite drives the
bare binary, so assert what this layer can promise.
Left failing on purpose, each reproduced identically on stock Firefox:
test_page_clock::test_should_pause (clock resumes 1-5ms late: 1002 here,
1005 stock), test_page_add_locator_handler::test_should_wait_for_hidden_by_default_2,
test_navigation's empty-url popup readyState, and
test_frame_goto_should_continue_after_client_redirect -- that last one is a
genuine race in the networkidle accounting (a subframe's navigationCommitted
can land after its subresource requests, and Playwright clears inflight
bookkeeping on commit), flaky in both: 3/10 wrong here, 1/10 on stock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After the screencastFrameAck/timestamp fix, recording worked headless but still
produced nothing usable anywhere else: `headless="virtual"` and plain headful
both emitted a valid .webm containing 24 pure-white frames -- Playwright's
filler for a screencast that never delivered a frame.
nsScreencastService only has a working source when the browser is headless
(HeadlessWindowCapturer). Outside headless, CreateWindowCapturer falls through
to libwebrtc's X11 window capturer, which fails three different ways:
* no XComposite -> startVideoRecording() succeeds and then never delivers a
frame. This is Camoufox's own Xvfb configuration, which passes
`-extension COMPOSITE`;
* XComposite enabled -> the browser segfaults during capture (reproduced on
the shipped 152.0.4-beta.28 as well, so it is not specific to this branch);
* Wayland -> nsWindow::GetNativeData(NS_NATIVE_WINDOW_WEBRTC_DEVICE_ID) is
documented as unhandled and returns null, so the service throws
NS_ERROR_FAILURE ("Failed to get native window id") and no capture starts.
Capture from the compositor instead when not headless, via
WindowGlobalParent.drawSnapshot() -- the same call Page.screenshot already
uses, which is why screenshots have always worked in every mode. It renders
page content directly and does not care about the windowing system.
The tick is ack-driven, mirroring nsScreencastService's kMaxFramesInFlight = 1,
so a slow consumer throttles capture rather than queueing JPEGs. Headless keeps
the native C++ capturer, which is cheaper and already correct.
Measured on the packaged Linux build, 3s recording of an animated page, frames
decoded to PNG and inspected rather than trusting file existence:
before after
headless 100 frames, real unchanged, real
headless="virtual" 24 frames, all white 100 frames, real
headful (Xvfb, X11) 24 frames, all white 99 frames, real
headful (Wayland env) no capture at all 99 frames, real
tests/async/test_video.py passes 5/5 both headless and headful. Enabling
Composite no longer crashes either, since X11 window capture is now unused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two harness defects, both of which silently destroyed coverage rather than
reporting anything useful:
* conftest's session `event_loop` fixture called asyncio.get_event_loop() with
no running loop. That is deprecated on 3.12 and raises on 3.14, and because
it is a fixture every async test errored at setup: a full run reported 1151
errors and 0 passes in 10s, which reads like a catastrophic browser failure
and is not. Allocate and install a fresh loop instead.
* async/test_page_route.py imported playwright._impl._glob.glob_to_regex, which
Playwright renamed to glob_to_regex_pattern (now returning the pattern string
rather than a compiled regex). A module-level ImportError dropped all 43
route tests from collection -- the tests covering exactly the code path the
routed-header fix touches. Shim it, keeping the old helper's semantics.
Collection goes from 1182 tests + 1 error to 1225 tests, and the suite runs
under both 3.12 and 3.14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9654452 enabled Xvfb's Composite extension on the theory that #93 (no video
under headless="virtual") was caused by disabling it. Measurement disproves it:
composite off + record_video_dir -> valid .webm, 24 pure-white frames
composite ON + record_video_dir -> browser dies with SIGSEGV, no video
composite ON + no recording -> fine
So compositing does not fix#93, and defaulting it on turns a blank recording
into a crash for anyone recording under a virtual display. The segfault
reproduces on the shipped 152.0.4-beta.28 too, so it is a pre-existing fault in
the screencast capture path rather than something this branch introduced -- but
that is exactly why it should not be reached by default.
Kept as an opt-in (CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=1) for hosts with real
GL, where it may behave differently. The real-screen-size half of 9654452 is
unaffected and stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Video recording was completely broken: record_video_dir left an empty
directory. Reproduced identically on the shipped 152.0.4-beta.28, so this is
long-standing, not a regression from this branch.
Two independent defects in the screencast path, both from juggler having
drifted behind Playwright's Firefox delegate:
1. Protocol.js required `screencastId` on Page.screencastFrameAck, but
Playwright acks with no parameters at all. Every ack was rejected by the
dispatcher, and because the client sends it via sendMayFail it never
noticed. nsScreencastService allows kMaxFramesInFlight = 1, so an unacked
frame stalls capture permanently -- exactly one frame was ever emitted.
2. The Page.screencastFrame event carried no `timestamp`. Playwright's Firefox
delegate does `event.timestamp * 1e3` with no fallback (its WebKit delegate
guards with `?? Date.now()`), so every frame reached VideoRecorder with a
NaN wall time and nothing was written to ffmpeg's stdin. ffmpeg then died
with "Error opening output file" and no .webm appeared.
Fixing only (1) restores frame flow (1 -> 75 frames) but still yields no file;
both are required.
Verified on the packaged Linux build by decoding the result rather than
checking that a file exists: 640x480, 3.96s, 99 frames, with the animated test
content present in the decoded PNGs (hundreds of distinct colours per frame).
Video under headless="virtual" is still broken -- it records 24 pure-white
frames -- and is left open as #93.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every script in tests/patches/ launches through AsyncCamoufox/Camoufox, which
sets FONTCONFIG_FILE from the installed bundle and expects the packaged fonts/
tree. Pointed at an unpackaged `make build` output, font init fails
("[GFX1]: no fonts - init: 1 fonts: 74"), pending idle-startup work
(BuiltInThemes.ensureBuiltInThemes, XPIProvider cleanupTemporaryAddons,
SessionSaver) turns into a quit-application shutdown blocker, and Playwright
force-kills the browser after its graceful-close timeout. That surfaces as a
TargetClosedError on a later new_page(), which reads like a browser crash and is
easy to misattribute to whatever change is under test.
Measured for the same commit, 6 pages per run:
shipped beta.28 (packaged) 0/5 failed
this build UNPACKAGED 5/5 failed
this build PACKAGED 0/5 failed
`make tests` is unaffected -- tests/conftest.py launches via plain Playwright
rather than the camoufox wrapper, so it works against obj-*/dist/bin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two hardcoded Xvfb arguments, both verified against a live Xvfb with xdpyinfo.
#458 -- `-screen 0 1x1x24`. A 1x1 root window is not a plausible desktop: it
breaks anything that measures the screen, and it is the reason
clamp_screen_to_display() has to special-case virtual displays (a generated
fingerprint would otherwise be clamped to 1x1). Default to 1920x1080x24;
the framebuffer cost is ~8MB. Overridable per-run with
CAMOUFOX_VIRTUAL_DISPLAY_SIZE="1920x1080[x24]", which is validated and rejects
malformed values rather than passing them to Xvfb.
#93 -- `-extension COMPOSITE`. Offscreen rendering needs Composite, which is
what Playwright's video recording uses, so disabling it silently broke
record_video_dir under headless="virtual". A real X server has the extension,
so enabling it is also the more faithful default. Set
CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=0 to restore the old behaviour.
Verified with xdpyinfo against real Xvfb instances:
default -> dimensions 1920x1080, Composite present
screen="800x600x24", composite=False -> dimensions 800x600, Composite absent
CAMOUFOX_VIRTUAL_DISPLAY_SIZE=2560x1440 -> resolves to 2560x1440x24
CAMOUFOX_VIRTUAL_DISPLAY_SIZE=bogus -> VirtualDisplayNotSupported
xvfb_args becomes a property so the two settings can vary per instance; the
existing VirtualDisplay(debug=...) call sites are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1. assets/base.mozconfig: add --disable-debug-symbols.
--disable-debug was already set, but that only strips DEBUG code paths; it
does not drop -g. So the gkrust fat-LTO staticlib and the libxul link both
carried full debug info, which is what puts linux/arm64 container builds into
the OOM killer. Nothing in scripts/package.py reads symbol files, so this is
pure cost for a release artifact.
2. Dockerfile: install ccache.
base.mozconfig enables it only `if command -v ccache >/dev/null`, and the apt
list never included it -- so the conditional was always false in-image and
every container build was a cold build, despite /root/.mozbuild being a
VOLUME.
3. Add .dockerignore.
`COPY . /app` was shipping .git and any locally extracted camoufox-*/ tree
into the build context. Measured with a scratch `COPY . /app`: context
transfer drops from 1.6GB to 988MB (.git alone is 624MB), and a developer
with an extracted Firefox tree was previously sending tens of GB.
The remaining 931MB is bundle/fonts, which scripts/package.py genuinely
reads, so it stays. Verified that nothing in Makefile, multibuild.py,
copy-additions.sh, patch.py or package.py references the excluded paths
(tests/, build-tester/, service-tester/, docs/, example/).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`git clean -fdx` removes the untracked mach script, so the following
`./mach clobber` in the same command failed with
"/bin/sh: 1: ./mach: not found" and `make clean` never actually clobbered the
object directory. Run clobber first, then clean.
Also fall back to re-extracting the source when the tree has no .git at all,
instead of failing in `make revert`.
Reapplied by hand from PR #520 (the original commit conflicted). The PR's
scripts/patch.py half is dropped: that reset line is already correct on main
(`git reset --hard unpatched && ./mach clobber && git clean -fdx`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>