fix(dotnet): unwrap page/frame args so NewCDPSessionAsync works through wrappers
context.NewCDPSessionAsync(page) threw NullReferenceException for any licensed .NET user: Playwright down-casts the IPage/IFrame argument to its concrete Page/Frame to read .Guid, which fails on the CloakBrowser proxy the wrapper returns. The license-guard proxy (all licensed launches) and the humanize decorator both produced a handle that fails that cast. - guard proxy unwraps page/frame/handle arguments before forwarding, keeps page.Context guarded, and Wrap() is idempotent - HumanizedBrowserContext.NewCDPSessionAsync unwraps its argument (covers the keyless+humanize path with no guard proxy) - HumanizedPage.Context re-wraps so page.Context stays humanized - public Humanize.Unwrap(page/frame) escape hatch - regression tests build the guard proxy and assert the inner receives raw
This commit is contained in:
@@ -169,6 +169,7 @@ internal static class LicenseGuard
|
||||
public static object Wrap(object target, string? denialPath)
|
||||
{
|
||||
if (denialPath == null) return target;
|
||||
if (target is IGuardedProxy) return target; // already guarded — don't nest proxies
|
||||
return target switch
|
||||
{
|
||||
IPage p => WrapAs<IPage>(p, denialPath),
|
||||
@@ -178,6 +179,32 @@ internal static class LicenseGuard
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peel every CloakBrowser wrapper — the license-guard <see cref="DispatchProxy"/> first,
|
||||
/// then the humanize decorator — off a page/frame/element handle to recover the raw
|
||||
/// Playwright object. A few Playwright methods (notably
|
||||
/// <see cref="IBrowserContext.NewCDPSessionAsync(IPage)"/>) internally down-cast the
|
||||
/// handle they are given to Playwright's concrete type, which throws on any wrapper; those
|
||||
/// must receive the unwrapped handle. Non-wrapped objects are returned unchanged.
|
||||
/// </summary>
|
||||
public static object Unwrap(object handle)
|
||||
{
|
||||
var current = handle;
|
||||
while (true)
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
case IGuardedProxy g: current = g.GuardTarget; break;
|
||||
case HumanizedPage p: current = p.Original; break;
|
||||
case HumanizedFrame f: current = f.Original; break;
|
||||
case HumanizedElementHandle e: current = e.Original; break;
|
||||
case HumanizedBrowserContext c: current = c.Original; break;
|
||||
case HumanizedBrowser b: current = b.Original; break;
|
||||
default: return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static T WrapAs<T>(T target, string denialPath) where T : class
|
||||
{
|
||||
var proxy = DispatchProxy.Create<T, LicenseGuardProxy<T>>();
|
||||
@@ -195,11 +222,22 @@ internal static class LicenseGuard
|
||||
/// getters + the EventEmitter surface). <c>get_Pages</c> is special-cased so a persistent
|
||||
/// context's already-open pages are guarded too.
|
||||
/// </summary>
|
||||
internal class LicenseGuardProxy<T> : DispatchProxy where T : class
|
||||
internal interface IGuardedProxy
|
||||
{
|
||||
/// <summary>The object this proxy forwards to (see <see cref="LicenseGuard.Unwrap"/>).</summary>
|
||||
object GuardTarget { get; }
|
||||
}
|
||||
|
||||
internal class LicenseGuardProxy<T> : DispatchProxy, IGuardedProxy where T : class
|
||||
{
|
||||
private T _target = null!;
|
||||
private string _denialPath = null!;
|
||||
|
||||
// One guard wrapper per underlying context, so page.Context is a stable instance.
|
||||
private readonly System.Runtime.CompilerServices.ConditionalWeakTable<IBrowserContext, object> _contextGuardCache = new();
|
||||
|
||||
object IGuardedProxy.GuardTarget => _target;
|
||||
|
||||
internal void Init(T target, string denialPath)
|
||||
{
|
||||
_target = target;
|
||||
@@ -216,6 +254,12 @@ internal class LicenseGuardProxy<T> : DispatchProxy where T : class
|
||||
// Guard the pages a persistent context hands back (context.Pages[0]).
|
||||
if (targetMethod.Name == "get_Pages" && value is IReadOnlyList<IPage> pages)
|
||||
return pages.Select(p => (IPage)LicenseGuard.Wrap(p, _denialPath)).ToList();
|
||||
// Guard the context a page hands back (page.Context) so calls made through it —
|
||||
// notably NewCDPSessionAsync, which unwraps its page/frame argument — stay guarded.
|
||||
// Memoized per underlying context so repeated page.Context accesses return the
|
||||
// same instance (reference identity parity with raw Playwright).
|
||||
if (targetMethod.Name == "get_Context" && value is IBrowserContext ctx)
|
||||
return _contextGuardCache.GetValue(ctx, c => LicenseGuard.Wrap((IBrowserContext)c, _denialPath));
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -250,7 +294,7 @@ internal class LicenseGuardProxy<T> : DispatchProxy where T : class
|
||||
/// caller sees the genuine Playwright exception.</summary>
|
||||
private object? Forward(MethodInfo method, object?[]? args)
|
||||
{
|
||||
try { return method.Invoke(_target, args); }
|
||||
try { return method.Invoke(_target, UnwrapArgs(args)); }
|
||||
catch (TargetInvocationException tie) when (tie.InnerException != null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(tie.InnerException).Throw();
|
||||
@@ -258,6 +302,28 @@ internal class LicenseGuardProxy<T> : DispatchProxy where T : class
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unwrap any page/frame/element-handle arguments back to their raw Playwright objects
|
||||
/// before the real method runs. Playwright methods that take a handle (e.g.
|
||||
/// NewCDPSessionAsync) down-cast it to a concrete type and throw on a wrapper. Returns the
|
||||
/// original array untouched when nothing needs unwrapping (the common case).
|
||||
/// </summary>
|
||||
private static object?[]? UnwrapArgs(object?[]? args)
|
||||
{
|
||||
if (args == null) return null;
|
||||
object?[]? copy = null;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] is IPage or IFrame or IElementHandle)
|
||||
{
|
||||
var unwrapped = LicenseGuard.Unwrap(args[i]!);
|
||||
if (!ReferenceEquals(unwrapped, args[i]))
|
||||
(copy ??= (object?[])args.Clone())[i] = unwrapped;
|
||||
}
|
||||
}
|
||||
return copy ?? args;
|
||||
}
|
||||
|
||||
private async Task GuardTask(MethodInfo method, object?[]? args)
|
||||
{
|
||||
try { await ((Task)Forward(method, args)!).ConfigureAwait(false); }
|
||||
|
||||
@@ -40,9 +40,15 @@ public static class Humanize
|
||||
public static IBrowserContext Context(IBrowserContext context, HumanConfig? config = null)
|
||||
{
|
||||
if (context is HumanizedBrowserContext) return context;
|
||||
return new HumanizedBrowserContext(context, config ?? new HumanConfig());
|
||||
// Memoize per raw context so page.Context returns a stable instance (Playwright's
|
||||
// own context is a singleton); a fresh wrapper each access would break reference
|
||||
// identity and dictionary-key use.
|
||||
return ContextCache.GetValue(context, key => new HumanizedBrowserContext(key, config ?? new HumanConfig()));
|
||||
}
|
||||
|
||||
/// <summary>One humanized wrapper per raw context, so identity stays stable across accesses.</summary>
|
||||
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IBrowserContext, HumanizedBrowserContext> ContextCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IBrowser"/> so every context/page it produces
|
||||
/// is humanized.
|
||||
@@ -54,6 +60,22 @@ public static class Humanize
|
||||
return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless, headlessNoViewport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recover the raw Playwright <see cref="IPage"/> behind any CloakBrowser wrapper
|
||||
/// (humanize decorator and/or license-guard proxy). Pass the result to Playwright APIs
|
||||
/// that reject wrapped handles, e.g. <c>context.NewCDPSessionAsync(Humanize.Unwrap(page))</c>.
|
||||
/// </summary>
|
||||
public static IPage Unwrap(IPage page) => (IPage)LicenseGuard.Unwrap(page);
|
||||
|
||||
/// <summary>Recover the raw Playwright <see cref="IFrame"/> behind any CloakBrowser wrapper.</summary>
|
||||
public static IFrame Unwrap(IFrame frame) => (IFrame)LicenseGuard.Unwrap(frame);
|
||||
|
||||
/// <summary>Recover the raw Playwright <see cref="IBrowserContext"/> behind any CloakBrowser wrapper.</summary>
|
||||
public static IBrowserContext Unwrap(IBrowserContext context) => (IBrowserContext)LicenseGuard.Unwrap(context);
|
||||
|
||||
/// <summary>Recover the raw Playwright <see cref="IElementHandle"/> behind any CloakBrowser wrapper.</summary>
|
||||
public static IElementHandle Unwrap(IElementHandle handle) => (IElementHandle)LicenseGuard.Unwrap(handle);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal re-wrap helpers (shared by the wrappers).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -38,4 +38,14 @@ public sealed partial class HumanizedBrowserContext : IBrowserContext
|
||||
|
||||
public async Task<IPage> RunAndWaitForPageAsync(System.Func<Task> action, BrowserContextRunAndWaitForPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(await _inner.RunAndWaitForPageAsync(action, options).ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
|
||||
// NewCDPSessionAsync internally down-casts its IPage/IFrame argument to Playwright's
|
||||
// concrete Page/Frame (reads .Guid) instead of using the interface, so a humanized (or
|
||||
// guard-proxied) handle throws NullReferenceException. Unwrap the argument to the raw
|
||||
// Playwright object before delegating.
|
||||
public Task<ICDPSession> NewCDPSessionAsync(IPage page) =>
|
||||
_inner.NewCDPSessionAsync((IPage)LicenseGuard.Unwrap(page));
|
||||
|
||||
public Task<ICDPSession> NewCDPSessionAsync(IFrame frame) =>
|
||||
_inner.NewCDPSessionAsync((IFrame)LicenseGuard.Unwrap(frame));
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ public sealed partial class HumanizedPage : IPage
|
||||
public IMouse Mouse => _mouse;
|
||||
public IKeyboard Keyboard => _keyboard;
|
||||
|
||||
// Re-wrap the owning context so pages/CDP sessions obtained via page.Context stay
|
||||
// humanized (the generator would otherwise forward the raw Playwright context).
|
||||
public IBrowserContext Context => Humanize.Context(_inner.Context, _cfg);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized selector actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
@@ -170,4 +171,106 @@ public class BrowserContextWrapperTests
|
||||
Assert.Same(browser, human.Original);
|
||||
Assert.Same(browser, human.Inner);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NewCDPSessionAsync: the page/frame argument must reach Playwright unwrapped.
|
||||
// Playwright down-casts it to its concrete Page/Frame (reads .Guid), which throws
|
||||
// NullReferenceException on any wrapper. Regression for the .NET 0.5.4 report.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Unwrap_peels_guard_proxy_and_humanize_off_a_page()
|
||||
{
|
||||
var raw = MakeFakePage();
|
||||
var humanized = await Humanize.PageAsync(raw, new HumanConfig());
|
||||
var denialPath = License.MintDenialFile()!;
|
||||
var guarded = (IPage)LicenseGuard.Wrap(humanized, denialPath);
|
||||
|
||||
Assert.NotSame(raw, guarded);
|
||||
Assert.Same(raw, LicenseGuard.Unwrap(guarded)); // internal peeler
|
||||
Assert.Same(raw, Humanize.Unwrap(guarded)); // public escape hatch
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guarded_context_forwards_unwrapped_page_to_NewCDPSession()
|
||||
{
|
||||
var raw = MakeFakePage();
|
||||
var humanized = await Humanize.PageAsync(raw, new HumanConfig());
|
||||
var denialPath = License.MintDenialFile()!;
|
||||
var guardedPage = (IPage)LicenseGuard.Wrap(humanized, denialPath);
|
||||
|
||||
var (cdp, _) = Fake.Of<ICDPSession>();
|
||||
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
|
||||
ctxRec.On("NewCDPSessionAsync", _ => Task.FromResult(cdp));
|
||||
var guardedCtx = (IBrowserContext)LicenseGuard.Wrap(ctx, denialPath);
|
||||
|
||||
await guardedCtx.NewCDPSessionAsync(guardedPage);
|
||||
|
||||
// The inner Playwright context must receive the RAW page, not a wrapper.
|
||||
Assert.Same(raw, ctxRec.Last("NewCDPSessionAsync")!.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Humanized_context_unwraps_page_for_NewCDPSession()
|
||||
{
|
||||
var raw = MakeFakePage();
|
||||
var humanized = await Humanize.PageAsync(raw, new HumanConfig());
|
||||
|
||||
var (cdp, _) = Fake.Of<ICDPSession>();
|
||||
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
|
||||
ctxRec.On("NewCDPSessionAsync", _ => Task.FromResult(cdp));
|
||||
var humanCtx = Humanize.Context(ctx, new HumanConfig());
|
||||
|
||||
await humanCtx.NewCDPSessionAsync(humanized);
|
||||
|
||||
Assert.Same(raw, ctxRec.Last("NewCDPSessionAsync")!.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HumanizedPage_Context_stays_humanized()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 800, Height = 600 });
|
||||
var (rawCtx, _) = Fake.Of<IBrowserContext>();
|
||||
pageRec.On("Context", rawCtx);
|
||||
|
||||
var humanized = await Humanize.PageAsync(page, new HumanConfig());
|
||||
|
||||
// page.Context must not leak the raw context (else page.Context.NewPageAsync()
|
||||
// silently returns an un-humanized page).
|
||||
Assert.IsType<HumanizedBrowserContext>(humanized.Context);
|
||||
|
||||
// ...and it must be the SAME instance across accesses (identity parity with
|
||||
// Playwright's singleton context; a fresh wrapper each call breaks == / dict keys).
|
||||
Assert.Same(humanized.Context, humanized.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unwrap_recovers_raw_context_through_guard_and_humanize()
|
||||
{
|
||||
var (rawCtx, _) = Fake.Of<IBrowserContext>();
|
||||
var humanized = Humanize.Context(rawCtx, new HumanConfig());
|
||||
var denialPath = License.MintDenialFile()!;
|
||||
var guarded = (IBrowserContext)LicenseGuard.Wrap(humanized, denialPath);
|
||||
|
||||
Assert.NotSame(rawCtx, guarded);
|
||||
Assert.Same(rawCtx, Humanize.Unwrap(guarded)); // peels guard proxy + humanize decorator
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guarded_page_Context_is_stable_across_accesses()
|
||||
{
|
||||
var raw = MakeFakePage();
|
||||
var (rawCtx, _) = Fake.Of<IBrowserContext>();
|
||||
((FakeProxy)(object)raw).On("Context", rawCtx);
|
||||
var humanized = await Humanize.PageAsync(raw, new HumanConfig());
|
||||
var denialPath = License.MintDenialFile()!;
|
||||
var guardedPage = (IPage)LicenseGuard.Wrap(humanized, denialPath);
|
||||
|
||||
Assert.Same(guardedPage.Context, guardedPage.Context);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user