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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user