diff --git a/additions/juggler/input/MouseDispatch.js b/additions/juggler/input/MouseDispatch.js index e4c8c6e..f5e2a02 100644 --- a/additions/juggler/input/MouseDispatch.js +++ b/additions/juggler/input/MouseDispatch.js @@ -183,6 +183,14 @@ export class MouseDispatch { * point it belongs to, and it is taken even for a point that is then skipped, * so dropping a point shifts nothing that follows it in time. * + * The pauses are a schedule counted from the start of the curve, not gaps + * taken after each ack: every point waits until its own due time. Waiting a + * full gap after the ack added each ack's latency to the curve, so a move ran + * as long as its plan plus one round trip per point: on a page busy 19ms in + * every 20, moves planned at 0.5s took 0.54-0.71s (tests/patches/ + * humanize-pacing.py). A late point is dispatched as soon as it can be; the + * ones after it are still due on the original clock. + * * Points outside the viewport are skipped, and a curve leaves the viewport * more often than it sounds: measured over 400 random moves, 69% of Cursory * paths stray outside the box spanned by their own endpoints, by up to 184px. @@ -215,9 +223,15 @@ export class MouseDispatch { // dispatched is the only place that check is reliable. let lastX = NaN; let lastY = NaN; + const startMs = ChromeUtils.now(); + let dueMs = 0; + const untilDue = () => { + const waitMs = startMs + dueMs - ChromeUtils.now(); + return waitMs > 0 ? new Promise(resolve => setTimeout(resolve, waitMs)) : null; + }; for (const [x, y, delayMs] of steps) { - if (delayMs > 0) - await new Promise(resolve => setTimeout(resolve, delayMs)); + dueMs += delayMs; + await untilDue(); if (!this.isInViewport(x, y)) continue; if (Math.round(x) === lastX && Math.round(y) === lastY) @@ -227,8 +241,8 @@ export class MouseDispatch { lastX = Math.round(x); lastY = Math.round(y); } - if (trailingDelayMs > 0) - await new Promise(resolve => setTimeout(resolve, trailingDelayMs)); + dueMs += trailingDelayMs; + await untilDue(); return true; } diff --git a/ci/run_patch_guards.py b/ci/run_patch_guards.py index 23db405..94921a2 100644 --- a/ci/run_patch_guards.py +++ b/ci/run_patch_guards.py @@ -51,6 +51,7 @@ GROUPS: Dict[str, Tuple[str, ...]] = { "force-scope-access", "humanize-edge-deadlock", "humanize-mouse-trajectory", + "humanize-pacing", "input-ack-backstop", "isolated-evaluate", "main-world-eval", diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index b3e7f9f..3574dca 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -1429,15 +1429,16 @@ def launch_options( if allow_addon_new_tab: set_into(config, 'allowAddonNewtab', True) - # Set Firefox user preferences + # Set Firefox user preferences. Each toggle writes its pref on or off: a + # persistent profile keeps a user.js pref in prefs.js after the launch that + # set it, so a flag that only wrote when on stayed on for good. if block_images: LeakWarning.warn('block_images', i_know_what_im_doing) - firefox_user_prefs['permissions.default.image'] = 2 - if block_webrtc: - firefox_user_prefs['media.peerconnection.enabled'] = False if disable_coop: LeakWarning.warn('disable_coop', i_know_what_im_doing) - firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = False + firefox_user_prefs.setdefault('permissions.default.image', 2 if block_images else 1) + firefox_user_prefs.setdefault('media.peerconnection.enabled', not block_webrtc) + firefox_user_prefs.setdefault('browser.tabs.remote.useCrossOriginOpenerPolicy', not disable_coop) # A persistent context takes its context options here. if launch_options.get('is_mobile'): LeakWarning.warn('is_mobile', i_know_what_im_doing) diff --git a/pythonlib/tests/test_launch_environment.py b/pythonlib/tests/test_launch_environment.py index db393b2..a486916 100644 --- a/pythonlib/tests/test_launch_environment.py +++ b/pythonlib/tests/test_launch_environment.py @@ -171,6 +171,41 @@ class TestUiLocaleFollowsIntlLocale: assert prefs[self.PREF] == "de" +class TestToggleSettingsAreWrittenBothWays: + """A persistent profile keeps every pref it was ever launched with. + + Firefox saves a user.js pref into prefs.js, and a later launch that simply + omits it does not clear it. A profile once launched with block_webrtc kept + WebRTC off after the flag was removed (a real profile's prefs.js still held + media.peerconnection.enabled=false), so each toggle writes its pref on every + launch, on or off. + """ + + UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0" + TOGGLES = [ + ("block_webrtc", "media.peerconnection.enabled", True, False), + ("block_images", "permissions.default.image", 1, 2), + ("disable_coop", "browser.tabs.remote.useCrossOriginOpenerPolicy", True, False), + ] + + def _prefs(self, **kwargs): + return utils.launch_options( + config={"navigator.userAgent": self.UA}, i_know_what_im_doing=True, **kwargs + )["firefox_user_prefs"] + + @pytest.mark.parametrize("flag, pref, stock, toggled", TOGGLES) + def test_off_writes_the_stock_value(self, isolated_launch_dependencies, flag, pref, stock, toggled): + assert self._prefs()[pref] == stock + + @pytest.mark.parametrize("flag, pref, stock, toggled", TOGGLES) + def test_on_writes_the_toggled_value(self, isolated_launch_dependencies, flag, pref, stock, toggled): + assert self._prefs(**{flag: True})[pref] == toggled + + @pytest.mark.parametrize("flag, pref, stock, toggled", TOGGLES) + def test_caller_pref_wins(self, isolated_launch_dependencies, flag, pref, stock, toggled): + assert self._prefs(firefox_user_prefs={pref: toggled})[pref] == toggled + + class TestPrefsReachStartup: """Launcher prefs must be readable by camoufox.cfg at startup. diff --git a/tests/patches/humanize-pacing.py b/tests/patches/humanize-pacing.py new file mode 100644 index 0000000..1c69d09 --- /dev/null +++ b/tests/patches/humanize-pacing.py @@ -0,0 +1,81 @@ +""" +Verify a humanized move takes the time its path planned, on a busy page. + +`sendTrajectoryAcked()` (additions/juggler/input/MouseDispatch.js) dispatches +the intermediate points of a humanized move. Each point carries the pause to +take before it, and each dispatch waits for the renderer's ack. Waiting the +full pause after the ack added every ack's latency to the move, so the move +took its plan plus one round trip per point. A page whose main thread is busy +acks late, and a 0.5 s move of ~25 points ran 0.56-0.9 s. + +With the pauses kept as a schedule from the start of the move, a late ack +delays only its own point, and the move still ends on its planned time. + +The page here keeps its main thread busy 19 ms of every 20, the way a heavy +page does. humanize=0.5 caps every move at 0.5 s; the long moves below always +reach the cap, so each should take ~0.5 s. Eleven moves are timed from the +page's side and their median is checked, so one move delayed by something +else (a GC, the host) does not decide the result. + +Run against a specific build: + CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python tests/patches/humanize-pacing.py + +What PASS means: + * the median humanized move on a busy page lasts at most 8% longer than + the humanize cap it was scaled to. +""" + +import asyncio +import os +import statistics +import sys + +from camoufox.async_api import AsyncCamoufox + +CAP_S = 0.5 +MAX_MEDIAN_MS = CAP_S * 1000 * 1.08 +MOVES = 11 + +EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH") + +BUSY_PAGE = """ + setInterval(() => { const t = performance.now(); while (performance.now() - t < 19); }, 20); + window.moves = []; + addEventListener("mousemove", () => moves.push(performance.now())); +""" + + +async def main() -> int: + kwargs = dict(headless=True, os="linux", humanize=CAP_S) + if EXECUTABLE_PATH: + kwargs["executable_path"] = EXECUTABLE_PATH + async with AsyncCamoufox(**kwargs) as browser: + page = await browser.new_page() + await page.set_content('') + await page.evaluate(BUSY_PAGE) + viewport = await page.evaluate("({w: innerWidth, h: innerHeight})") + corners = [(20, 20), (viewport["w"] - 20, viewport["h"] - 20)] + await page.mouse.move(*corners[0]) + + durations = [] + for i in range(MOVES): + start = await page.evaluate("moves.length") + await page.mouse.move(*corners[(i + 1) % 2]) + times = await page.evaluate(f"moves.slice({start})") + if len(times) >= 3: + durations.append(round(times[-1] - times[0])) + + print(f"move durations (ms): {sorted(durations)}") + if len(durations) < MOVES - 2: + print(f"FAIL: only {len(durations)} of {MOVES} moves were humanized") + return 1 + median = statistics.median(durations) + if median <= MAX_MEDIAN_MS: + print(f"PASS: median {median:.0f}ms within {MAX_MEDIAN_MS:.0f}ms of a {CAP_S * 1000:.0f}ms plan") + return 0 + print(f"FAIL: median {median:.0f}ms; the move took its plan plus the page's ack latency") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index 7c5a42f..609f726 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -1683,19 +1683,19 @@ export async function launchOptions({ setInto(config, "allowAddonNewtab", true); } - // Set Firefox user preferences + // Set Firefox user preferences. Each toggle writes its pref on or off: a + // persistent profile keeps a user.js pref in prefs.js after the launch that + // set it, so a flag that only wrote when on stayed on for good. if (block_images) { LeakWarning.warn("block_images", i_know_what_im_doing); - firefox_user_prefs["permissions.default.image"] = 2; - } - if (block_webrtc) { - firefox_user_prefs["media.peerconnection.enabled"] = false; } if (disable_coop) { LeakWarning.warn("disable_coop", i_know_what_im_doing); - firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] = - false; } + firefox_user_prefs["permissions.default.image"] ??= block_images ? 2 : 1; + firefox_user_prefs["media.peerconnection.enabled"] ??= !block_webrtc; + firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] ??= + !disable_coop; // A persistent context takes its context options here. if (passthrough.is_mobile || passthrough.isMobile) { LeakWarning.warn("is_mobile", i_know_what_im_doing); diff --git a/typescript/tests/launch.test.ts b/typescript/tests/launch.test.ts index b0f9e18..a9b5c5e 100644 --- a/typescript/tests/launch.test.ts +++ b/typescript/tests/launch.test.ts @@ -175,6 +175,38 @@ describe("test_launch_environment: prefs", () => { }); }); + describe("toggle settings are written both ways (test_launch_environment.py)", () => { + // A persistent profile keeps a user.js pref in prefs.js, so a toggle that + // only wrote when on stayed on after the flag was removed. + const TOGGLES: [string, string, unknown, unknown][] = [ + ["block_webrtc", "media.peerconnection.enabled", true, false], + ["block_images", "permissions.default.image", 1, 2], + [ + "disable_coop", + "browser.tabs.remote.useCrossOriginOpenerPolicy", + true, + false, + ], + ]; + for (const [flag, pref, stock, toggled] of TOGGLES) { + it(`${flag} off writes the stock ${pref}`, async () => { + expect((await prefsFor(ua(LINUX_UA)))[pref]).toBe(stock); + }); + it(`${flag} on writes the toggled ${pref}`, async () => { + expect((await prefsFor({ ...ua(LINUX_UA), [flag]: true }))[pref]).toBe( + toggled, + ); + }); + it(`the caller's ${pref} wins`, async () => { + const prefs = await prefsFor({ + ...ua(LINUX_UA), + firefox_user_prefs: { [pref]: toggled }, + }); + expect(prefs[pref]).toBe(toggled); + }); + } + }); + describe("UI locale follows the Intl locale", () => { const PREF = "intl.locale.requested"; it("a default identity pins en-US", async () => {