diff --git a/src/PlaywrightNative.Tests/LibraryBrowserContextProxyParityTests.cs b/src/PlaywrightNative.Tests/LibraryBrowserContextProxyParityTests.cs index d5ca0f0f..1bb00df9 100644 --- a/src/PlaywrightNative.Tests/LibraryBrowserContextProxyParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryBrowserContextProxyParityTests.cs @@ -29,10 +29,7 @@ namespace PlaywrightNative.Tests { /// /// Official library/browsercontext-proxy.spec.ts parity. - /// Skipped (Node-only channel type validation): - /// should throw for bad server value (proxy.server: expected - /// string, got number; C# is already a - /// string). Do not edit leftover ContextProxyTests, + /// Do not edit leftover ContextProxyTests, /// ContextProxyAuthTests, or LoopbackHttpProxy. /// [TestFixture] @@ -167,6 +164,24 @@ public async Task ShouldWorkWhenPassingTheProxyOnlyOnTheContextLevel() } } + [PlaywrightTest("browsercontext-proxy.spec.ts", "should ignore legacy 'per-context' launch proxy")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public Task ShouldIgnoreLegacyPerContextLaunchProxy() + => ShouldIgnoreLegacyLaunchProxyAsync("per-context"); + + [PlaywrightTest("browsercontext-proxy.spec.ts", "should ignore legacy 'http://per-context' launch proxy")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public Task ShouldIgnoreLegacyHttpPerContextLaunchProxy() + => ShouldIgnoreLegacyLaunchProxyAsync("http://per-context"); + + [PlaywrightTest("browsercontext-proxy.spec.ts", "should throw for bad server value")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void ShouldThrowForBadServerValue() + => Assert.Ignore("Proxy.Server is a string in C#, so a numeric server value cannot be passed."); + [PlaywrightTest("browsercontext-proxy.spec.ts", "should use proxy")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] @@ -768,6 +783,34 @@ private async Task ProxyLocalNetworkAsync(string target, bool additionalBypass) private string[] NonFaviconUrls() => _proxy.RequestUrls.Where(url => url.IndexOf("favicon", StringComparison.Ordinal) < 0).ToArray(); + private async Task ShouldIgnoreLegacyLaunchProxyAsync(string launchProxy) + { + EnsureServer(); + _proxy.ForwardTo(ServerPort, allowConnectRequests: true); + IBrowser browser = await BrowserLauncher.LaunchAsync(proxy: new Proxy { Server = launchProxy }).ConfigureAwait(false); + try + { + IBrowserContext context = await browser.NewContextAsync(new() { Proxy = new Proxy { Server = _proxy.Host } }).ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + await page.GoToAsync("http://non-existent.com/target.html").ConfigureAwait(false); + Assert.That(await page.TitleAsync().ConfigureAwait(false), Is.EqualTo("Served by the proxy")); + IAPIResponse response = await context.APIRequest.GetAsync("http://non-existent.com/target.html").ConfigureAwait(false); + Assert.That(await response.TextAsync().ConfigureAwait(false), Does.Contain("Served by the proxy")); + Assert.That(_proxy.ConnectHosts, Does.Contain("non-existent.com:80")); + + IBrowserContext directContext = await browser.NewContextAsync().ConfigureAwait(false); + IPage directPage = await directContext.NewPageAsync().ConfigureAwait(false); + await directPage.GoToAsync(Prefix + "/target.html").ConfigureAwait(false); + Assert.That(await directPage.TitleAsync().ConfigureAwait(false), Is.EqualTo("Served by the proxy")); + IAPIResponse directResponse = await directContext.APIRequest.GetAsync(Prefix + "/target.html").ConfigureAwait(false); + Assert.That(directResponse.Ok, Is.True); + } + finally + { + await DisposeQuietlyAsync(browser).ConfigureAwait(false); + } + } + private static async Task CatchGotoAsync(IPage page, string url) { try diff --git a/src/PlaywrightNative.Tests/LibraryClientCertificatesParityTests.cs b/src/PlaywrightNative.Tests/LibraryClientCertificatesParityTests.cs index ec4fbb1d..cf4f4a97 100644 --- a/src/PlaywrightNative.Tests/LibraryClientCertificatesParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryClientCertificatesParityTests.cs @@ -512,6 +512,41 @@ public async Task BrowserShouldPassWithMatchingCertificatesAndWhenAHttpProxyIsUs } } + [PlaywrightTest("client-certificates.spec.ts", "should respect launch proxy and proxy bypass")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task BrowserShouldRespectLaunchProxyAndProxyBypass() + { + await using OfficialClientCertificateServer server = await StartCcServerAsync() + .ConfigureAwait(false); + Uri serverUri = new Uri(server.Url); + await using OfficialTestProxy proxyServer = new OfficialTestProxy(); + proxyServer.ForwardTo(serverUri.Port, allowConnectRequests: true); + string proxy = "localhost:" + proxyServer.Port.ToString(CultureInfo.InvariantCulture); + string serverHost = ProxiedConnectHost + ":" + serverUri.Port.ToString(CultureInfo.InvariantCulture); + ClientCertificate[] clientCertificates = new[] { Trusted(OriginOf(server.Url)) }; + await using IBrowser browser = await BrowserLauncher.LaunchAsync(proxy: new Proxy { Server = proxy }).ConfigureAwait(false); + { + IPage page = await browser.NewPageAsync(new() { IgnoreHTTPSErrors = true, ClientCertificates = clientCertificates }) + .ConfigureAwait(false); + await page.GoToAsync(server.Url).ConfigureAwait(false); + await Assertions.Expect(page.GetByTestId("message")).ToHaveTextAsync(TrustedMessage).ConfigureAwait(false); + // Headed Chromium also sends its own background requests through the launch proxy. + Assert.That(proxyServer.ConnectHosts, Does.Contain(serverHost)); + await page.CloseAsync().ConfigureAwait(false); + } + + proxyServer.ConnectHosts = Array.Empty(); + { + IPage page = await browser.NewPageAsync(new() { IgnoreHTTPSErrors = true, ClientCertificates = clientCertificates, Proxy = new Proxy { Server = proxy, Bypass = serverUri.Host } }) + .ConfigureAwait(false); + await page.GoToAsync(server.Url).ConfigureAwait(false); + await Assertions.Expect(page.GetByTestId("message")).ToHaveTextAsync(TrustedMessage).ConfigureAwait(false); + Assert.That(proxyServer.ConnectHosts, Does.Not.Contain(serverHost)); + await page.CloseAsync().ConfigureAwait(false); + } + } + [PlaywrightTest("client-certificates.spec.ts", "should pass with matching certificates and when a socks proxy is used")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative.Tests/LibraryFetchProxyParityTests.cs b/src/PlaywrightNative.Tests/LibraryFetchProxyParityTests.cs index ea3b3f7b..5bd06c43 100644 --- a/src/PlaywrightNative.Tests/LibraryFetchProxyParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryFetchProxyParityTests.cs @@ -361,6 +361,56 @@ public async Task ShouldSendCorrectAlpnProtocolToHttpsProxy() } } + [PlaywrightTest("fetch-proxy.spec.ts", "should apply proxy.bypass to redirect targets")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldApplyProxyBypassToRedirectTargets() + { + EnsureServer(); + string crossProcessHost = new Uri(CrossProcessPrefix).Authority; + if (crossProcessHost == new Uri(Prefix).Authority) + { + Assert.Ignore("Needs two different host names for the same server"); + } + + _proxy.ForwardTo(ServerPort, allowConnectRequests: true); + Server.SetRedirect("/redirect-to-cross-process", CrossProcessPrefix + "/simple.json"); + Server.SetRedirect("/redirect-to-same-origin", Prefix + "/simple.json"); + IBrowserContext context = await _browser.NewContextAsync(new() + { + Proxy = new Proxy + { + Server = "localhost:" + _proxy.Port.ToString(CultureInfo.InvariantCulture), + Bypass = new Uri(Prefix).Host, + } + }).ConfigureAwait(false); + try + { + { + // Bypassed first hop redirects to a host that must go through the proxy. + IAPIResponse response = await context.APIRequest.GetAsync(Prefix + "/redirect-to-cross-process").ConfigureAwait(false); + Assert.That(response.Url, Is.EqualTo(CrossProcessPrefix + "/simple.json")); + JsonElement? json = await response.JsonAsync().ConfigureAwait(false); + Assert.That(json.Value.GetProperty("foo").GetString(), Is.EqualTo("bar")); + Assert.That(_proxy.ConnectHosts, Is.EqualTo(new[] { crossProcessHost })); + _proxy.ConnectHosts = Array.Empty(); + } + + { + // Proxied first hop redirects to a bypassed host. + IAPIResponse response = await context.APIRequest.GetAsync(CrossProcessPrefix + "/redirect-to-same-origin").ConfigureAwait(false); + Assert.That(response.Url, Is.EqualTo(Prefix + "/simple.json")); + JsonElement? json = await response.JsonAsync().ConfigureAwait(false); + Assert.That(json.Value.GetProperty("foo").GetString(), Is.EqualTo("bar")); + Assert.That(_proxy.ConnectHosts, Is.EqualTo(new[] { crossProcessHost })); + } + } + finally + { + await context.CloseAsync().ConfigureAwait(false); + } + } + private static void EnsureServer() { if (Server == null) diff --git a/src/PlaywrightNative.Tests/LibraryProxyParityTests.cs b/src/PlaywrightNative.Tests/LibraryProxyParityTests.cs index 83086755..6f0c3b16 100644 --- a/src/PlaywrightNative.Tests/LibraryProxyParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryProxyParityTests.cs @@ -28,10 +28,7 @@ namespace PlaywrightNative.Tests { /// /// Official library/proxy.spec.ts parity. Launch-level - /// browserType.launch({ proxy }). Skipped (Node-only type - /// validation): should throw for bad server value - /// (proxy.server: expected string, got number; C# - /// is already a string). Official + /// browserType.launch({ proxy }). Official /// it.fixme on should use proxy with emulated user agent. /// Do not edit leftover ContextProxyTests, /// ContextProxyAuthTests, or LoopbackHttpProxy. @@ -130,6 +127,12 @@ public async Task TearDownAsync() TestServerSetup.HttpsServer?.Reset(); } + [PlaywrightTest("proxy.spec.ts", "should throw for bad server value")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void ShouldThrowForBadServerValue() + => Assert.Ignore("Proxy.Server is a string in C#, so a numeric server value cannot be passed."); + [PlaywrightTest("proxy.spec.ts", "should use proxy @smoke")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative/BrowserTypeLaunchOptions.cs b/src/PlaywrightNative/BrowserTypeLaunchOptions.cs index 288b4915..f548d737 100644 --- a/src/PlaywrightNative/BrowserTypeLaunchOptions.cs +++ b/src/PlaywrightNative/BrowserTypeLaunchOptions.cs @@ -76,9 +76,9 @@ public class BrowserTypeLaunchOptions public BrowserChannel Channel { get; set; } /// - /// Network proxy used by the browser process. Chromium also needs a launch-level - /// proxy (even a dummy per-context server) when contexts override - /// with their own proxy. + /// Network proxy used by the browser process. Contexts can override it with their + /// own proxy in . The + /// legacy per-context placeholder server is ignored. /// public Proxy Proxy { get; set; } diff --git a/src/PlaywrightNative/Helpers/ProxySettings.cs b/src/PlaywrightNative/Helpers/ProxySettings.cs index 110cdc06..5e871338 100644 --- a/src/PlaywrightNative/Helpers/ProxySettings.cs +++ b/src/PlaywrightNative/Helpers/ProxySettings.cs @@ -34,6 +34,16 @@ internal static class ProxySettings internal static bool HasCredentials(Proxy proxy) => proxy != null && !string.IsNullOrEmpty(proxy.Username); + /// + /// Official browserType._validateLaunchOptions: drops the legacy + /// per-context placeholder launch proxy. It is no longer needed + /// for contexts to set their own proxy. + /// + /// Launch proxy, or . + /// The launch proxy, or for the placeholder. + internal static Proxy NormalizeLaunchProxy(Proxy proxy) + => proxy?.Server is "per-context" or "http://per-context" ? null : proxy; + /// /// Builds a proxy server URL. Adds an http:// scheme when missing. /// When is and diff --git a/src/PlaywrightNative/Playwright.cs b/src/PlaywrightNative/Playwright.cs index 4ff0e5ba..304e3787 100644 --- a/src/PlaywrightNative/Playwright.cs +++ b/src/PlaywrightNative/Playwright.cs @@ -104,17 +104,18 @@ public static async Task LaunchChromiumAsync(BrowserTypeLaunchOptions { options ??= new BrowserTypeLaunchOptions(); BrowserTypeLaunchGuard.ThrowIfLaunchForbidden(options); + Proxy proxy = ProxySettings.NormalizeLaunchProxy(options.Proxy); try { string executablePath = await ResolveExecutablePathAsync(SupportedBrowser.Chromium, options).ConfigureAwait(false); PlaywrightNative.Chromium.CRBrowser crBrowser = await PlaywrightNative.Chromium.ChromiumBrowserType - .LaunchAsync(executablePath, options.Headless, args: ToArgArray(options.Args), proxy: options.Proxy, chromiumSandbox: options.ChromiumSandbox, timeout: ResolveTimeout(options), ignoreDefaultArgs: options.IgnoreDefaultArgs, environment: options.Env, loggerFactory: options.LoggerFactory, devtools: options.Devtools, handleSIGINT: HandleSIGINT(options), handleSIGTERM: HandleSIGTERM(options), handleSIGHUP: HandleSIGHUP(options), ignoreDefaultArgsList: options.IgnoreDefaultArgsList) + .LaunchAsync(executablePath, options.Headless, args: ToArgArray(options.Args), proxy: proxy, chromiumSandbox: options.ChromiumSandbox, timeout: ResolveTimeout(options), ignoreDefaultArgs: options.IgnoreDefaultArgs, environment: options.Env, loggerFactory: options.LoggerFactory, devtools: options.Devtools, handleSIGINT: HandleSIGINT(options), handleSIGTERM: HandleSIGTERM(options), handleSIGHUP: HandleSIGHUP(options), ignoreDefaultArgsList: options.IgnoreDefaultArgsList) .ConfigureAwait(false); PlaywrightNative.Chromium.ChromiumBrowser chromium = new PlaywrightNative.Chromium.ChromiumBrowser(crBrowser, ResolveDownloadsPath(options), options.Logger) { - LaunchProxy = options.Proxy, + LaunchProxy = proxy, }; ((PlaywrightNative.Helpers.IHasTracesDir)chromium).TracesDir = options.TracesDir; ((PlaywrightNative.Helpers.IHasArtifactsDir)chromium).ArtifactsDir = options.ArtifactsDir; @@ -170,16 +171,17 @@ public static async Task LaunchWebkitAsync(BrowserTypeLaunchOptions op { options ??= new BrowserTypeLaunchOptions(); BrowserTypeLaunchGuard.ThrowIfLaunchForbidden(options); + Proxy proxy = ProxySettings.NormalizeLaunchProxy(options.Proxy); try { string executablePath = await ResolveExecutablePathAsync(SupportedBrowser.Webkit, options).ConfigureAwait(false); WebKit.WKBrowser wkBrowser = await WebKit.WebkitBrowserType - .LaunchAsync(executablePath, options.Headless, args: ToArgArray(options.Args), proxy: options.Proxy, timeout: ResolveTimeout(options), environment: options.Env, loggerFactory: options.LoggerFactory, handleSIGINT: HandleSIGINT(options), handleSIGTERM: HandleSIGTERM(options), handleSIGHUP: HandleSIGHUP(options)) + .LaunchAsync(executablePath, options.Headless, args: ToArgArray(options.Args), proxy: proxy, timeout: ResolveTimeout(options), environment: options.Env, loggerFactory: options.LoggerFactory, handleSIGINT: HandleSIGINT(options), handleSIGTERM: HandleSIGTERM(options), handleSIGHUP: HandleSIGHUP(options)) .ConfigureAwait(false); wkBrowser.LaunchDownloadsPath = ResolveDownloadsPath(options); - wkBrowser.LaunchProxy = options.Proxy; + wkBrowser.LaunchProxy = proxy; wkBrowser.Logger = options.Logger; ((PlaywrightNative.Helpers.IHasTracesDir)wkBrowser).TracesDir = options.TracesDir; ((PlaywrightNative.Helpers.IHasArtifactsDir)wkBrowser).ArtifactsDir = options.ArtifactsDir; @@ -202,6 +204,7 @@ internal static async Task LaunchChromiumPersistentContextAsync options ??= new BrowserTypeLaunchOptions(); BrowserTypeLaunchGuard.ThrowIfPersistentForbidden(options); ThrowIfPageArgument(options.Args); + Proxy proxy = ProxySettings.NormalizeLaunchProxy(options.Proxy); ClientCertificatesProxy certsProxy = null; string resolvedDir = null; try @@ -209,7 +212,7 @@ internal static async Task LaunchChromiumPersistentContextAsync string executablePath = await ResolveExecutablePathAsync(SupportedBrowser.Chromium, options).ConfigureAwait(false); bool ownsUserDataDir = string.IsNullOrEmpty(userDataDir); resolvedDir = ResolveUserDataDir(userDataDir); - Proxy launchProxy = StartPersistentClientCertificates(options, out certsProxy); + Proxy launchProxy = StartPersistentClientCertificates(options, proxy, out certsProxy); PlaywrightNative.Chromium.CRBrowser crBrowser = await PlaywrightNative.Chromium.ChromiumBrowserType .LaunchAsync( @@ -234,14 +237,14 @@ internal static async Task LaunchChromiumPersistentContextAsync PlaywrightNative.Chromium.ChromiumBrowser instance = new(crBrowser, ResolveDownloadsPath(options)) { - LaunchProxy = options.Proxy, + LaunchProxy = proxy, }; ((PlaywrightNative.Helpers.IHasTracesDir)instance).TracesDir = options.TracesDir; ((PlaywrightNative.Helpers.IHasArtifactsDir)instance).ArtifactsDir = options.ArtifactsDir; IBrowserContext context = instance.PersistentContext(); if (context is PlaywrightNative.Chromium.ChromiumBrowserContext chromiumCerts) { - chromiumCerts.AttachClientCertificatesProxy(certsProxy, options.Proxy); + chromiumCerts.AttachClientCertificatesProxy(certsProxy, proxy); certsProxy = null; } @@ -315,13 +318,14 @@ internal static async Task LaunchWebkitPersistentContextAsync(s options ??= new BrowserTypeLaunchOptions(); BrowserTypeLaunchGuard.ThrowIfPersistentForbidden(options); ThrowIfPageArgument(options.Args); + Proxy proxy = ProxySettings.NormalizeLaunchProxy(options.Proxy); ClientCertificatesProxy webkitCertsProxy = null; try { string executablePath = await ResolveExecutablePathAsync(SupportedBrowser.Webkit, options).ConfigureAwait(false); bool ownsUserDataDir = string.IsNullOrEmpty(userDataDir); string resolvedDir = ResolveUserDataDir(userDataDir); - Proxy webkitLaunchProxy = StartPersistentClientCertificates(options, out webkitCertsProxy); + Proxy webkitLaunchProxy = StartPersistentClientCertificates(options, proxy, out webkitCertsProxy); WebKit.WKBrowser wkBrowser = await WebKit.WebkitBrowserType .LaunchAsync( @@ -341,13 +345,13 @@ internal static async Task LaunchWebkitPersistentContextAsync(s .ConfigureAwait(false); wkBrowser.LaunchDownloadsPath = ResolveDownloadsPath(options); - wkBrowser.LaunchProxy = options.Proxy; + wkBrowser.LaunchProxy = proxy; ((PlaywrightNative.Helpers.IHasTracesDir)wkBrowser).TracesDir = options.TracesDir; ((PlaywrightNative.Helpers.IHasArtifactsDir)wkBrowser).ArtifactsDir = options.ArtifactsDir; IBrowserContext context = wkBrowser.PersistentContext(); if (context is WebKit.WKBrowserContext webkitCerts) { - webkitCerts.AttachClientCertificatesProxy(webkitCertsProxy, options.Proxy); + webkitCerts.AttachClientCertificatesProxy(webkitCertsProxy, proxy); webkitCertsProxy = null; } @@ -396,19 +400,20 @@ await PatchWebKitNavigatorLanguageAsync(webkitPatch, persistentPatch.Locale) private static Proxy StartPersistentClientCertificates( BrowserTypeLaunchOptions options, + Proxy launchProxy, out ClientCertificatesProxy proxy) { proxy = null; if (options is not BrowserTypeLaunchPersistentContextOptions persistent || !ClientCertificateHelper.HasAny(persistent.ClientCertificates)) { - return options?.Proxy; + return launchProxy; } proxy = ClientCertificatesProxy.Create( persistent.ClientCertificates, persistent.IgnoreHTTPSErrors == true, - options.Proxy); + launchProxy); return proxy.BrowserProxy; }