feat: version-gate headless no_viewport across all three wrappers

Headed launches already skip Playwright's emulated viewport (no_viewport)
because a fixed CDP viewport on a real window forces outerWidth < innerWidth,
a physically impossible geometry that's a bot tell. Headless historically kept
a fixed DEFAULT_VIEWPORT since there's no window chrome to make coherent
without emulation — but a newer Chromium build (>=148.0.7778.215.4) now
reports coherent dimensions in headless too, so it can drop the emulated
viewport the same way headed does.

Added binary_supports_headless_no_viewport() (mirrored in Python, JS, .NET) to
gate this per resolved binary version: local overrides with no declared
version stay on the old fixed-viewport path (unknown version = safe default),
while an explicit browser_version/CLOAKBROWSER_VERSION always wins so internal
builds can opt in. HEADLESS_NO_VIEWPORT_MIN_VERSION is currently set to a
version that hasn't shipped yet, so this is a no-op until that build is live —
behavior is byte-identical to today for every existing binary.
This commit is contained in:
CloakHQ
2026-07-02 03:21:14 +02:00
parent 60d978826c
commit f54c870a88
14 changed files with 396 additions and 42 deletions
+10 -3
View File
@@ -55,7 +55,11 @@ public static class CloakLauncher
: null;
// Pass headless so headed handles default new pages/contexts to NoViewport
// (track the real window - see CloakBrowserHandle.ApplyDefaultNoViewport).
return new CloakBrowserHandle(playwright, browser, options.Humanize, humanCfg, options.Headless);
// headlessNoViewport extends that default to headless on newer binaries.
bool headlessNoViewport =
Config.BinarySupportsHeadlessNoViewport(options.LicenseKey, options.BrowserVersion);
return new CloakBrowserHandle(
playwright, browser, options.Humanize, humanCfg, options.Headless, headlessNoViewport);
}
catch
{
@@ -449,8 +453,11 @@ public static class CloakLauncher
return ViewportSize.NoViewport;
if (options.Viewport != null)
return new ViewportSize { Width = options.Viewport.Value.Width, Height = options.Viewport.Value.Height };
// Viewport unset: headed tracks the real window; headless gets the fixed default.
return options.Headless
// Viewport unset: headed tracks the real window; headless on a newer binary also
// tracks it (coherent dimensions natively), older headless gets the fixed default.
bool headlessNoViewport = options.Headless
&& Config.BinarySupportsHeadlessNoViewport(options.LicenseKey, options.BrowserVersion);
return options.Headless && !headlessNoViewport
? new ViewportSize { Width = Config.DefaultViewportWidth, Height = Config.DefaultViewportHeight }
: ViewportSize.NoViewport;
}
+53
View File
@@ -373,4 +373,57 @@ public static class Config
/// </summary>
public static string? GetLocalBinaryOverride() =>
Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
// First Chromium build that reports coherent headless dimensions without an
// emulated viewport. On these binaries the wrapper launches headless with no
// viewport; older binaries need a fixed default viewport to stay coherent.
// null => not shipped yet; feature off, behavior byte-identical to today.
// TODO: set to the chromium version string that first ships it.
public static readonly string? HeadlessNoViewportMinVersion = "148.0.7778.215.4";
/// <summary>
/// Whether headless can launch without an emulated viewport on the resolved binary.
/// Only binaries at or above <see cref="HeadlessNoViewportMinVersion"/> qualify; older
/// ones keep the fixed default viewport. A local override binary
/// (<c>CLOAKBROWSER_BINARY_PATH</c>) is unknown-version, so stay on the safe path.
/// </summary>
public static bool BinarySupportsHeadlessNoViewport(string? licenseKey = null, string? browserVersion = null)
{
if (HeadlessNoViewportMinVersion == null)
return false;
// A declared version (browserVersion arg OR CLOAKBROWSER_VERSION env) wins even
// under a local override — the caller asserts the version (also how internal builds
// opt in). Only an override with no declared version stays on the safe path.
string? declared;
try
{
declared = NormalizeRequestedVersion(browserVersion);
}
catch
{
declared = null;
}
string version;
if (!string.IsNullOrEmpty(declared))
{
version = declared!;
}
else if (GetLocalBinaryOverride() != null)
{
return false;
}
else
{
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
version = GetEffectiveVersion(pro);
}
try
{
return !VersionNewer(HeadlessNoViewportMinVersion, version);
}
catch
{
return false;
}
}
}
+6 -4
View File
@@ -15,6 +15,7 @@ public sealed class CloakBrowserHandle : IAsyncDisposable
private readonly HumanConfig? _humanCfg;
private readonly IBrowser _rawBrowser;
private readonly bool _headless;
private readonly bool _headlessNoViewport;
/// <summary>
/// The Playwright browser. When humanize is enabled this is a transparent
@@ -32,20 +33,21 @@ public sealed class CloakBrowserHandle : IAsyncDisposable
internal IPlaywright PlaywrightInstance => _playwright;
internal CloakBrowserHandle(IPlaywright playwright, IBrowser browser, bool humanize, HumanConfig? humanCfg,
bool headless = true)
bool headless = true, bool headlessNoViewport = false)
{
_playwright = playwright;
_rawBrowser = browser;
_humanize = humanize;
_humanCfg = humanCfg;
_headless = headless;
_headlessNoViewport = headlessNoViewport;
// Wrap the whole browser so the entire object graph (contexts, pages, mice,
// keyboards, locators, frames) is humanized transparently. The wrapper is
// headless-aware so the headed no-viewport default also applies when pages are
// created through the humanized browser (parity with Python's _default_no_viewport,
// which patches the raw browser so the default holds on every path).
Browser = humanize
? Wrappers.Humanize.Browser(browser, humanCfg ?? new HumanConfig(), headless)
? Wrappers.Humanize.Browser(browser, humanCfg ?? new HumanConfig(), headless, headlessNoViewport)
: browser;
}
@@ -56,10 +58,10 @@ public sealed class CloakBrowserHandle : IAsyncDisposable
/// humanize wrapper). Port of Python <c>_default_no_viewport</c>.
/// </summary>
private BrowserNewPageOptions ApplyDefaultNoViewport(BrowserNewPageOptions? options) =>
ViewportDefaults.ApplyHeadedNoViewport(options, _headless);
ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport);
private BrowserNewContextOptions ApplyDefaultNoViewport(BrowserNewContextOptions? options) =>
ViewportDefaults.ApplyHeadedNoViewport(options, _headless);
ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport);
/// <summary>
/// Create a new browser context. On headed launches without an explicit viewport,
+6 -4
View File
@@ -16,18 +16,20 @@ namespace CloakBrowser;
/// </summary>
internal static class ViewportDefaults
{
public static BrowserNewPageOptions ApplyHeadedNoViewport(BrowserNewPageOptions? options, bool headless)
public static BrowserNewPageOptions ApplyHeadedNoViewport(
BrowserNewPageOptions? options, bool headless, bool headlessNoViewport = false)
{
if (headless) return options ?? new BrowserNewPageOptions();
var o = options ?? new BrowserNewPageOptions();
if (headless && !headlessNoViewport) return o;
o.ViewportSize ??= ViewportSize.NoViewport;
return o;
}
public static BrowserNewContextOptions ApplyHeadedNoViewport(BrowserNewContextOptions? options, bool headless)
public static BrowserNewContextOptions ApplyHeadedNoViewport(
BrowserNewContextOptions? options, bool headless, bool headlessNoViewport = false)
{
if (headless) return options ?? new BrowserNewContextOptions();
var o = options ?? new BrowserNewContextOptions();
if (headless && !headlessNoViewport) return o;
o.ViewportSize ??= ViewportSize.NoViewport;
return o;
}
+3 -2
View File
@@ -47,10 +47,11 @@ public static class Humanize
/// Wrap a raw Playwright <see cref="IBrowser"/> so every context/page it produces
/// is humanized.
/// </summary>
public static IBrowser Browser(IBrowser browser, HumanConfig? config = null, bool headless = true)
public static IBrowser Browser(
IBrowser browser, HumanConfig? config = null, bool headless = true, bool headlessNoViewport = false)
{
if (browser is HumanizedBrowser) return browser;
return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless);
return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless, headlessNoViewport);
}
// -----------------------------------------------------------------------
@@ -16,12 +16,14 @@ public sealed partial class HumanizedBrowser : IBrowser
private readonly IBrowser _inner;
private readonly HumanConfig _cfg;
private readonly bool _headless;
private readonly bool _headlessNoViewport;
internal HumanizedBrowser(IBrowser inner, HumanConfig cfg, bool headless = true)
internal HumanizedBrowser(IBrowser inner, HumanConfig cfg, bool headless = true, bool headlessNoViewport = false)
{
_inner = inner;
_cfg = cfg;
_headless = headless;
_headlessNoViewport = headlessNoViewport;
}
/// <summary>The original, un-humanized Playwright browser (escape hatch).</summary>
@@ -32,12 +34,12 @@ public sealed partial class HumanizedBrowser : IBrowser
public async Task<IPage> NewPageAsync(BrowserNewPageOptions? options = null) =>
await Humanize.WrapPageAsync(
await _inner.NewPageAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless)).ConfigureAwait(false),
await _inner.NewPageAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport)).ConfigureAwait(false),
_cfg).ConfigureAwait(false);
public async Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null) =>
Humanize.Context(
await _inner.NewContextAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless)).ConfigureAwait(false),
await _inner.NewContextAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport)).ConfigureAwait(false),
_cfg);
public IReadOnlyList<IBrowserContext> Contexts =>
@@ -179,3 +179,62 @@ public class ConfigTests
}
}
}
/// <summary>
/// Config.BinarySupportsHeadlessNoViewport() — parity-critical: Python and JS mirror
/// this gate. Threshold is an unshipped version, so the resolved-version path is a
/// no-op today; the declared-version path is what these tests pin. In env-serial
/// because the override tests mutate CLOAKBROWSER_BINARY_PATH.
/// </summary>
[Collection("env-serial")]
public class HeadlessNoViewportGateTests
{
[Fact]
public void DeclaredBelowThreshold_Off()
{
// Current live Pro version — one build below the threshold => feature OFF.
Assert.False(Config.BinarySupportsHeadlessNoViewport(browserVersion: "148.0.7778.215.3"));
}
[Fact]
public void DeclaredAtThreshold_On()
{
Assert.True(Config.BinarySupportsHeadlessNoViewport(browserVersion: "148.0.7778.215.4"));
}
[Fact]
public void DeclaredAboveThreshold_On()
{
Assert.True(Config.BinarySupportsHeadlessNoViewport(browserVersion: "149.0.0.0"));
}
[Fact]
public void DeclaredWinsOverLocalOverride()
{
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
try
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", "/fake/chrome");
Assert.True(Config.BinarySupportsHeadlessNoViewport(browserVersion: "149.0.0.0"));
}
finally
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", prev);
}
}
[Fact]
public void LocalOverrideWithoutDeclared_Off()
{
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
try
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", "/fake/chrome");
Assert.False(Config.BinarySupportsHeadlessNoViewport());
}
finally
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", prev);
}
}
}