From b205399d86a5578261f982494297d1e810888e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 23 Jan 2024 13:21:07 -0300 Subject: [PATCH] Cooperative request intercepts (#2403) * some progress * cr * cr * requestId is string * unflake test * prettier --- .../PageSetRequestInterceptionTests.cs | 787 ++++++++++++++++++ .../RequestContinueTests.cs | 127 +++ .../RequestRespondTests.cs | 149 ++++ .../SetRequestInterceptionTests.cs | 6 +- .../Screenshots/golden-chromium/test.png | Bin 33760 -> 5867 bytes lib/PuppeteerSharp/BrowserData/Firefox.cs | 2 +- lib/PuppeteerSharp/IPage.cs | 18 +- lib/PuppeteerSharp/IRequest.cs | 17 +- lib/PuppeteerSharp/Initiator.cs | 54 ++ lib/PuppeteerSharp/InitiatorType.cs | 40 + .../InterceptResolutionAction.cs | 33 + .../InterceptResolutionState.cs | 30 + .../Messaging/RequestWillBeSentPayload.cs | 6 +- lib/PuppeteerSharp/Messaging/StackTrace.cs | 6 + lib/PuppeteerSharp/NetworkManager.cs | 40 +- lib/PuppeteerSharp/Page.cs | 27 +- lib/PuppeteerSharp/Request.cs | 362 +++++--- lib/PuppeteerSharp/RequestAbortErrorCode.cs | 2 +- lib/PuppeteerSharp/RequestEventArgs.cs | 4 +- .../ResponseCreatedEventArgs.cs | 4 +- lib/PuppeteerSharp/ResponseData.cs | 2 +- lib/PuppeteerSharp/TaskManager.cs | 2 +- lib/PuppeteerSharp/WaitTask.cs | 13 +- 23 files changed, 1587 insertions(+), 144 deletions(-) create mode 100644 lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/PageSetRequestInterceptionTests.cs create mode 100644 lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestContinueTests.cs create mode 100644 lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestRespondTests.cs create mode 100644 lib/PuppeteerSharp/Initiator.cs create mode 100644 lib/PuppeteerSharp/InitiatorType.cs create mode 100644 lib/PuppeteerSharp/InterceptResolutionAction.cs create mode 100644 lib/PuppeteerSharp/InterceptResolutionState.cs diff --git a/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/PageSetRequestInterceptionTests.cs b/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/PageSetRequestInterceptionTests.cs new file mode 100644 index 000000000..c6ae141c4 --- /dev/null +++ b/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/PageSetRequestInterceptionTests.cs @@ -0,0 +1,787 @@ +// * MIT License +// * +// * Copyright (c) Darío Kondratiuk +// * +// * Permission is hereby granted, free of charge, to any person obtaining a copy +// * of this software and associated documentation files (the "Software"), to deal +// * in the Software without restriction, including without limitation the rights +// * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// * copies of the Software, and to permit persons to whom the Software is +// * furnished to do so, subject to the following conditions: +// * +// * The above copyright notice and this permission notice shall be included in all +// * copies or substantial portions of the Software. +// * +// * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// * SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using PuppeteerSharp.Tests.Attributes; +using PuppeteerSharp.Nunit; +using NUnit.Framework; +using PuppeteerSharp.Helpers; + +namespace PuppeteerSharp.Tests.RequestInterceptionExperimentalTests; + +public class PageSetRequestInterceptionTests : PuppeteerPageBaseTest +{ + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should cooperatively ${expectedAction} by priority")] + [Skip(SkipAttribute.Targets.Firefox)] + [TestCase("abort")] + [TestCase("continue")] + [TestCase("respond")] + public async Task ShouldCooperativelyActByPriority(string expectedAction) + { + var actionResults = new List(); + + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + if (request.Url.EndsWith(".css")) + { + var headers = request.Headers; + headers["xaction"] = "continue"; + return request.ContinueAsync(new Payload() { Headers = headers, }, + expectedAction == "continue" ? 1 : 0); + } + + return request.ContinueAsync(new Payload(), 0); + }); + + Page.AddRequestInterceptor(request => + { + if (request.Url.EndsWith(".css")) + { + Dictionary headers = []; + foreach (var kvp in request.Headers) + { + headers.Add(kvp.Key, kvp.Value); + } + + headers["xaction"] = "respond"; + return request.RespondAsync(new ResponseData() { Headers = headers, }, + expectedAction == "respond" ? 1 : 0); + } + + return request.ContinueAsync(new Payload(), 0); + }); + + Page.AddRequestInterceptor(request => + { + if (request.Url.EndsWith(".css")) + { + var headers = request.Headers; + headers["xaction"] = "abort"; + return request.AbortAsync(RequestAbortErrorCode.Aborted, expectedAction == "abort" ? 1 : 0); + } + + return request.ContinueAsync(new Payload(), 0); + }); + + Page.Response += (_, e) => + { + e.Response.Headers.TryGetValue("xaction", out var xaction); + + if (e.Response.Url.EndsWith(".css") && !string.IsNullOrEmpty(xaction)) + { + actionResults.Add(xaction); + } + }; + + Page.RequestFailed += (_, e) => + { + if (e.Request.Url.EndsWith(".css")) + { + actionResults.Add("abort"); + } + }; + + IResponse response; + + if (expectedAction == "continue") + { + var serverRequestTask = Server.WaitForRequest("/one-style.css", request => request.Headers["xaction"]); + response = await Page.GoToAsync(TestConstants.ServerUrl + "/one-style.html"); + await serverRequestTask; + actionResults.Add(serverRequestTask.Result); + } + else + { + response = await Page.GoToAsync(TestConstants.ServerUrl + "/one-style.html"); + } + + Assert.AreEqual(1, actionResults.Count); + Assert.AreEqual(expectedAction, actionResults[0]); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", "should intercept")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldIntercept() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(async request => + { + if (TestUtils.IsFavicon(request)) + { + await request.ContinueAsync(new Payload(), 0); + return; + } + + StringAssert.Contains("empty.html", request.Url); + Assert.NotNull(request.Headers); + Assert.NotNull(request.Headers["user-agent"]); + Assert.NotNull(request.Headers["accept"]); + Assert.AreEqual(HttpMethod.Get, request.Method); + Assert.Null(request.PostData); + Assert.True(request.IsNavigationRequest); + Assert.AreEqual(ResourceType.Document, request.ResourceType); + Assert.AreEqual(Page.MainFrame, request.Frame); + Assert.AreEqual(TestConstants.AboutBlank, request.Frame.Url); + await request.ContinueAsync(new Payload(), 0); + }); + var response = await Page.GoToAsync(TestConstants.EmptyPage); + Assert.True(response.Ok); + + Assert.AreEqual(TestConstants.Port, response.RemoteAddress.Port); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work when POST is redirected with 302")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWhenPostIsRedirectedWith302() + { + Server.SetRedirect("/rredirect", "/empty.html"); + await Page.GoToAsync(TestConstants.EmptyPage); + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(async request => await request.ContinueAsync(new Payload(), 0)); + + await Page.SetContentAsync(@" +
+ +
+ "); + await Task.WhenAll( + Page.QuerySelectorAsync("form").EvaluateFunctionAsync("form => form.submit()"), + Page.WaitForNavigationAsync() + ); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work when header manipulation headers with redirect")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWhenHeaderManipulationHeadersWithRedirect() + { + Server.SetRedirect("/rredirect", "/empty.html"); + await Page.SetRequestInterceptionAsync(true); + + Page.AddRequestInterceptor(async request => + { + var headers = request.Headers.Clone(); + headers["foo"] = "bar"; + await request.ContinueAsync(new Payload { Headers = headers }, 0); + }); + + await Page.GoToAsync(TestConstants.ServerUrl + "/rrredirect"); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should be able to remove headers")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldBeAbleToRemoveHeaders() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(async request => + { + var headers = request.Headers.Clone(); + headers["foo"] = "bar"; + headers.Remove("origin"); + await request.ContinueAsync(new Payload { Headers = headers }, 0); + }); + + var requestTask = Server.WaitForRequest("/empty.html", request => request.Headers["origin"]); + await Task.WhenAll( + requestTask, + Page.GoToAsync(TestConstants.ServerUrl + "/empty.html") + ); + Assert.True(string.IsNullOrEmpty(requestTask.Result)); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should contain referer header")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldContainRefererHeader() + { + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + var requestsReadyTcs = new TaskCompletionSource(); + + Page.AddRequestInterceptor(async request => + { + if (!TestUtils.IsFavicon(request)) + { + requests.Add(request); + + if (requests.Count > 1) + { + requestsReadyTcs.TrySetResult(true); + } + } + + await request.ContinueAsync(new Payload(), 0); + }); + + await Page.GoToAsync(TestConstants.ServerUrl + "/one-style.html"); + await requestsReadyTcs.Task.WithTimeout(); + StringAssert.Contains("/one-style.css", requests[1].Url); + StringAssert.Contains("/one-style.html", requests[1].Headers["Referer"]); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should properly return navigation response when URL has cookies")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldProperlyReturnNavigationResponseWhenURLHasCookies() + { + // Setup cookie. + await Page.GoToAsync(TestConstants.EmptyPage); + await Page.SetCookieAsync(new CookieParam { Name = "foo", Value = "bar" }); + + // Setup request interception. + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + var response = await Page.ReloadAsync(); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should stop intercepting")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldStopIntercepting() + { + await Page.SetRequestInterceptionAsync(true); + + async Task EventHandler(IRequest request) + { + await request.ContinueAsync(new Payload(), 0); + Page.RemoveRequestInterceptor(EventHandler); + } + + Page.AddRequestInterceptor(EventHandler); + await Page.GoToAsync(TestConstants.EmptyPage); + await Page.SetRequestInterceptionAsync(false); + await Page.GoToAsync(TestConstants.EmptyPage); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should show custom HTTP headers")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldShowCustomHTTPHeaders() + { + await Page.SetExtraHttpHeadersAsync(new Dictionary { ["foo"] = "bar" }); + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + Assert.AreEqual("bar", request.Headers["foo"]); + return request.ContinueAsync(new Payload(), 0); + }); + var response = await Page.GoToAsync(TestConstants.EmptyPage); + Assert.True(response.Ok); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with redirect inside sync XHR")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithRedirectInsideSyncXHR() + { + await Page.GoToAsync(TestConstants.EmptyPage); + Server.SetRedirect("/logo.png", "/pptr.png"); + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + + var status = await Page.EvaluateFunctionAsync(@"async () => + { + const request = new XMLHttpRequest(); + request.open('GET', '/logo.png', false); // `false` makes the request synchronous + request.send(null); + return request.status; + }"); + Assert.AreEqual(200, status); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with custom referer headers")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithCustomRefererHeaders() + { + await Page.SetExtraHttpHeadersAsync(new Dictionary { ["referer"] = TestConstants.EmptyPage }); + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + Assert.AreEqual(TestConstants.EmptyPage, request.Headers["referer"]); + return request.ContinueAsync(new Payload(), 0); + }); + var response = await Page.GoToAsync(TestConstants.EmptyPage); + Assert.True(response.Ok); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", "should be abortable")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldBeAbortable() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + if (request.Url.EndsWith(".css")) + { + return request.AbortAsync(RequestAbortErrorCode.Failed, 0); + } + else + { + return request.ContinueAsync(new Payload(), 0); + } + }); + var failedRequests = 0; + Page.RequestFailed += (_, _) => failedRequests++; + var response = await Page.GoToAsync(TestConstants.ServerUrl + "/one-style.html"); + Assert.True(response.Ok); + Assert.Null(response.Request.Failure); + Assert.AreEqual(1, failedRequests); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should be abortable with custom error codes")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldBeAbortableWithCustomErrorCodes() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.AbortAsync(RequestAbortErrorCode.InternetDisconnected, 0)); + IRequest failedRequest = null; + Page.RequestFailed += (_, e) => failedRequest = e.Request; + + var exception = Assert.ThrowsAsync( + () => Page.GoToAsync(TestConstants.EmptyPage)); + + StringAssert.StartsWith("net::ERR_INTERNET_DISCONNECTED", exception.Message); + Assert.NotNull(failedRequest); + Assert.AreEqual("net::ERR_INTERNET_DISCONNECTED", failedRequest.Failure); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", "should send referer")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldSendReferer() + { + await Page.SetExtraHttpHeadersAsync(new Dictionary { ["referer"] = "http://google.com/" }); + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + var requestTask = Server.WaitForRequest("/grid.html", request => request.Headers["referer"].ToString()); + await Task.WhenAll( + requestTask, + Page.GoToAsync(TestConstants.ServerUrl + "/grid.html") + ); + Assert.AreEqual("http://google.com/", requestTask.Result); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should fail navigation when aborting main resource")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldFailNavigationWhenAbortingMainResource() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.AbortAsync(RequestAbortErrorCode.Failed, 0)); + var exception = Assert.ThrowsAsync( + () => Page.GoToAsync(TestConstants.EmptyPage)); + + if (TestConstants.IsChrome) + { + StringAssert.Contains("net::ERR_FAILED", exception.Message); + } + else + { + StringAssert.Contains("NS_ERROR_FAILURE", exception.Message); + } + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with redirects")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithRedirects() + { + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + Page.AddRequestInterceptor(async request => + { + await request.ContinueAsync(new Payload(), 0); + requests.Add(request); + }); + + Server.SetRedirect("/non-existing-page.html", "/non-existing-page-2.html"); + Server.SetRedirect("/non-existing-page-2.html", "/non-existing-page-3.html"); + Server.SetRedirect("/non-existing-page-3.html", "/non-existing-page-4.html"); + Server.SetRedirect("/non-existing-page-4.html", "/empty.html"); + var response = await Page.GoToAsync(TestConstants.ServerUrl + "/non-existing-page.html"); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + StringAssert.Contains("empty.html", response.Url); + Assert.AreEqual(5, requests.Count); + Assert.AreEqual(ResourceType.Document, requests[2].ResourceType); + + // Check redirect chain + var redirectChain = response.Request.RedirectChain; + Assert.AreEqual(4, redirectChain.Length); + StringAssert.Contains("/non-existing-page.html", redirectChain[0].Url); + StringAssert.Contains("/non-existing-page-3.html", redirectChain[2].Url); + + for (var i = 0; i < redirectChain.Length; ++i) + { + var request = redirectChain[i]; + Assert.True(request.IsNavigationRequest); + Assert.AreEqual(request, request.RedirectChain.ElementAt(i)); + } + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with redirects for subresources")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithRedirectsForSubresources() + { + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + Page.AddRequestInterceptor(request => + { + if (!TestUtils.IsFavicon(request)) + { + requests.Add(request); + } + + return request.ContinueAsync(new Payload(), 0); + }); + + Server.SetRedirect("/one-style.css", "/two-style.css"); + Server.SetRedirect("/two-style.css", "/three-style.css"); + Server.SetRedirect("/three-style.css", "/four-style.css"); + Server.SetRoute("/four-style.css", + async context => { await context.Response.WriteAsync("body {box-sizing: border-box; }"); }); + + var response = await Page.GoToAsync(TestConstants.ServerUrl + "/one-style.html"); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + StringAssert.Contains("one-style.html", response.Url); + Assert.AreEqual(5, requests.Count); + Assert.AreEqual(ResourceType.Document, requests[0].ResourceType); + Assert.AreEqual(ResourceType.StyleSheet, requests[1].ResourceType); + + // Check redirect chain + var redirectChain = requests[1].RedirectChain; + Assert.AreEqual(3, redirectChain.Length); + StringAssert.Contains("one-style.css", redirectChain[0].Url); + StringAssert.Contains("three-style.css", redirectChain[2].Url); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should be able to abort redirects")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldBeAbleToAbortRedirects() + { + await Page.SetRequestInterceptionAsync(true); + Server.SetRedirect("/non-existing.json", "/non-existing-2.json"); + Server.SetRedirect("/non-existing-2.json", "/simple.html"); + Page.AddRequestInterceptor(request => + { + if (request.Url.Contains("non-existing-2")) + { + return request.AbortAsync(RequestAbortErrorCode.Failed, 0); + } + + return request.ContinueAsync(new Payload(), 0); + }); + + await Page.GoToAsync(TestConstants.EmptyPage); + var result = await Page.EvaluateFunctionAsync(@"async () => { + try + { + await fetch('/non-existing.json'); + } + catch (e) + { + return e.message; + } + }"); + + if (TestConstants.IsChrome) + { + StringAssert.Contains("Failed to fetch", result); + } + else + { + StringAssert.Contains("NetworkError", result); + } + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with equal requests")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithEqualRequests() + { + await Page.GoToAsync(TestConstants.EmptyPage); + var responseCount = 1; + Server.SetRoute("/zzz", context => context.Response.WriteAsync(((responseCount++) * 11) + string.Empty)); + await Page.SetRequestInterceptionAsync(true); + + var spinner = false; + // Cancel 2nd request. + Page.AddRequestInterceptor(request => + { + if (TestUtils.IsFavicon(request)) + { + return request.ContinueAsync(new Payload(), 0); + } + + if (spinner) + { + spinner = !spinner; + return request.AbortAsync(RequestAbortErrorCode.Failed, 0); + } + + spinner = !spinner; + return request.ContinueAsync(new Payload(), 0); + }); + + var results = await Page.EvaluateExpressionAsync(@"Promise.all([ + fetch('/zzz').then(response => response.text()).catch(e => 'FAILED'), + fetch('/zzz').then(response => response.text()).catch(e => 'FAILED'), + fetch('/zzz').then(response => response.text()).catch(e => 'FAILED'), + ])"); + Assert.AreEqual(new[] { "11", "FAILED", "22" }, results); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should navigate to dataURL and fire dataURL requests")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldNavigateToDataURLAndFireDataURLRequests() + { + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + Page.AddRequestInterceptor(request => + { + requests.Add(request); + return request.ContinueAsync(new Payload(), 0); + }); + + var dataURL = "data:text/html,
yo
"; + var response = await Page.GoToAsync(dataURL); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + Assert.That(requests, Has.Exactly(1).Items); + Assert.AreEqual(dataURL, requests[0].Url); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should be able to fetch dataURL and fire dataURL requests")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldBeAbleToFetchDataURLAndFireDataURLRequests() + { + await Page.GoToAsync(TestConstants.EmptyPage); + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + Page.AddRequestInterceptor(request => + { + requests.Add(request); + return request.ContinueAsync(new Payload(), 0); + }); + var dataURL = "data:text/html,
yo
"; + var text = await Page.EvaluateFunctionAsync("url => fetch(url).then(r => r.text())", dataURL); + + Assert.AreEqual("
yo
", text); + Assert.That(requests, Has.Exactly(1).Items); + Assert.AreEqual(dataURL, requests[0].Url); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should navigate to URL with hash and fire requests without hash")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldNavigateToURLWithHashAndAndFireRequestsWithoutHash() + { + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + Page.AddRequestInterceptor(request => + { + requests.Add(request); + return request.ContinueAsync(new Payload(), 0); + }); + var response = await Page.GoToAsync(TestConstants.EmptyPage + "#hash"); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + Assert.AreEqual(TestConstants.EmptyPage, response.Url); + Assert.That(requests, Has.Exactly(1).Items); + Assert.AreEqual(TestConstants.EmptyPage, requests[0].Url); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with encoded server")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithEncodedServer() + { + // The requestWillBeSent will report encoded URL, whereas interception will + // report URL as-is. @see crbug.com/759388 + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + var response = await Page.GoToAsync(TestConstants.ServerUrl + "/some nonexisting page"); + Assert.AreEqual(HttpStatusCode.NotFound, response.Status); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with badly encoded server")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithBadlyEncodedServer() + { + await Page.SetRequestInterceptionAsync(true); + Server.SetRoute("/malformed?rnd=%911", _ => Task.CompletedTask); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + var response = await Page.GoToAsync(TestConstants.ServerUrl + "/malformed?rnd=%911"); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with encoded server - 2")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithEncodedServerNegative2() + { + // The requestWillBeSent will report URL as-is, whereas interception will + // report encoded URL for stylesheet. @see crbug.com/759388 + await Page.SetRequestInterceptionAsync(true); + var requests = new List(); + Page.AddRequestInterceptor(request => + { + requests.Add(request); + return request.ContinueAsync(new Payload(), 0); + }); + var response = + await Page.GoToAsync( + $"data:text/html,"); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + Assert.AreEqual(2, requests.Count); + Assert.AreEqual(HttpStatusCode.NotFound, requests[1].Response.Status); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should not throw \"Invalid Interception Id\" if the request was cancelled")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldNotThrowInvalidInterceptionIdIfTheRequestWasCancelled() + { + await Page.SetContentAsync(""); + await Page.SetRequestInterceptionAsync(true); + IRequest request = null; + var requestIntercepted = new TaskCompletionSource(); + Page.Request += (_, e) => + { + request = e.Request; + requestIntercepted.SetResult(true); + }; + + var _ = Page.QuerySelectorAsync("iframe") + .EvaluateFunctionAsync("(frame, url) => frame.src = url", TestConstants.ServerUrl); + // Wait for request interception. + await requestIntercepted.Task; + // Delete frame to cause request to be canceled. + _ = Page.QuerySelectorAsync("iframe").EvaluateFunctionAsync("frame => frame.remove()"); + await request.ContinueAsync(new Payload(), 0); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should throw if interception is not enabled")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldThrowIfInterceptionIsNotEnabled() + { + Exception exception = null; + Page.AddRequestInterceptor(async request => + { + try + { + await request.ContinueAsync(new Payload(), 0); + } + catch (Exception ex) + { + exception = ex; + } + }); + await Page.GoToAsync(TestConstants.EmptyPage); + StringAssert.Contains("Request Interception is not enabled", exception.Message); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should work with file URLs")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkWithFileURLs() + { + await Page.SetRequestInterceptionAsync(true); + var urls = new List(); + Page.AddRequestInterceptor(request => + { + urls.Add(request.Url.Split('/').Last()); + return request.ContinueAsync(new Payload(), 0); + }); + + var uri = new Uri(Path.Combine(Directory.GetCurrentDirectory(), "Assets", "one-style.html")).AbsoluteUri; + await Page.GoToAsync(uri); + Assert.AreEqual(2, urls.Count); + Assert.Contains("one-style.html", urls); + Assert.Contains("one-style.css", urls); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should not cache if cache disabled")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldNotCacheIfCacheDisabled() + { + await Page.GoToAsync(TestConstants.ServerUrl + "/cached/one-style.html"); + await Page.SetRequestInterceptionAsync(true); + await Page.SetCacheEnabledAsync(false); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + + var cached = new List(); + Page.RequestServedFromCache += (_, e) => cached.Add(e.Request); + + await Page.ReloadAsync(); + Assert.IsEmpty(cached); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should cache if cache enabled")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldNotCacheIfCacheEnabled() + { + await Page.GoToAsync(TestConstants.ServerUrl + "/cached/one-style.html"); + await Page.SetRequestInterceptionAsync(true); + await Page.SetCacheEnabledAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + + var cached = new List(); + Page.RequestServedFromCache += (_, e) => cached.Add(e.Request); + + await Page.ReloadAsync(); + Assert.That(cached, Has.Exactly(1).Items); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Page.setRequestInterception", + "should load fonts if cache enabled")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldLoadFontsIfCacheEnabled() + { + await Page.SetRequestInterceptionAsync(true); + await Page.SetCacheEnabledAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload(), 0)); + + var waitTask = Page.WaitForResponseAsync(response => response.Url.EndsWith("/one-style.woff")); + await Page.GoToAsync(TestConstants.ServerUrl + "/cached/one-style-font.html"); + await waitTask; + } +} diff --git a/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestContinueTests.cs b/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestContinueTests.cs new file mode 100644 index 000000000..9ec627bb9 --- /dev/null +++ b/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestContinueTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using PuppeteerSharp.Tests.Attributes; +using PuppeteerSharp.Nunit; +using NUnit.Framework; + +namespace PuppeteerSharp.Tests.RequestInterceptionExperimentalTests; + +public class RequestContinueTests : PuppeteerPageBaseTest +{ + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.continue", "should work")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWork() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(async request => await request.ContinueAsync(new Payload(), 0)); + await Page.GoToAsync(TestConstants.EmptyPage); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.continue", "should amend HTTP headers")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldAmendHTTPHeaders() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + var headers = new Dictionary(request.Headers) { ["FOO"] = "bar" }; + return request.ContinueAsync(new Payload { Headers = headers }, 0); + }); + await Page.GoToAsync(TestConstants.EmptyPage); + var requestTask = Server.WaitForRequest("/sleep.zzz", request => request.Headers["foo"].ToString()); + await Task.WhenAll( + requestTask, + Page.EvaluateExpressionAsync("fetch('/sleep.zzz')") + ); + Assert.AreEqual("bar", requestTask.Result); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.continue", + "should redirect in a way non-observable to page")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldRedirectInAWayNonObservableToPage() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + var redirectURL = request.Url.Contains("/empty.html") + ? TestConstants.ServerUrl + "/consolelog.html" + : null; + return request.ContinueAsync(new Payload { Url = redirectURL }, 0); + }); + string consoleMessage = null; + Page.Console += (_, e) => consoleMessage = e.Message.Text; + await Page.GoToAsync(TestConstants.EmptyPage); + Assert.AreEqual(TestConstants.EmptyPage, Page.Url); + Assert.AreEqual("yellow", consoleMessage); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.continue", "should amend method")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldAmendMethodData() + { + await Page.GoToAsync(TestConstants.EmptyPage); + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload { Method = HttpMethod.Post }, 0)); + + var requestTask = Server.WaitForRequest("/sleep.zzz", request => request.Method); + + await Task.WhenAll( + requestTask, + Page.EvaluateExpressionAsync("fetch('/sleep.zzz')") + ); + + Assert.AreEqual("POST", requestTask.Result); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.continue", "should amend post data")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldAmendPostData() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync(new Payload { Method = HttpMethod.Post, PostData = "doggo" }, 0)); + var requestTask = Server.WaitForRequest("/sleep.zzz", async request => + { + using var reader = new StreamReader(request.Body, Encoding.UTF8); + return await reader.ReadToEndAsync(); + }); + + await Task.WhenAll( + requestTask, + Page.GoToAsync(TestConstants.ServerUrl + "/sleep.zzz") + ); + + Assert.AreEqual("doggo", await requestTask.Result); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.continue", + "should amend both post data and method on navigation")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldAmendBothPostDataAndMethodOnNavigation() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.ContinueAsync( + new Payload + { + Method = HttpMethod.Post, PostData = "doggo" + }, + 0)); + + var serverRequestTask = Server.WaitForRequest("/empty.html", async req => + { + var body = await new StreamReader(req.Body).ReadToEndAsync(); + return new { req.Method, Body = body }; + }); + + await Task.WhenAll( + serverRequestTask, + Page.GoToAsync(TestConstants.EmptyPage) + ); + var serverRequest = await serverRequestTask; + Assert.AreEqual(HttpMethod.Post.Method, serverRequest.Result.Method); + Assert.AreEqual("doggo", serverRequest.Result.Body); + } +} diff --git a/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestRespondTests.cs b/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestRespondTests.cs new file mode 100644 index 000000000..fc7f609a0 --- /dev/null +++ b/lib/PuppeteerSharp.Tests/RequestInterceptionExperimentalTests/RequestRespondTests.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using PuppeteerSharp.Tests.Attributes; +using PuppeteerSharp.Nunit; +using NUnit.Framework; + +namespace PuppeteerSharp.Tests.RequestInterceptionExperimentalTests; + +public class RequestRespondTests : PuppeteerPageBaseTest +{ + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.respond", "should work")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWork() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.RespondAsync(new ResponseData + { + Status = HttpStatusCode.Created, + Headers = new Dictionary { ["foo"] = "bar" }, + Body = "Yo, page!" + }, 0)); + + var response = await Page.GoToAsync(TestConstants.EmptyPage); + Assert.AreEqual(HttpStatusCode.Created, response.Status); + Assert.AreEqual("bar", response.Headers["foo"]); + Assert.AreEqual("Yo, page!", await Page.EvaluateExpressionAsync("document.body.textContent")); + } + + /// + /// In puppeteer this method is called ShouldWorkWithStatusCode422. + /// I found that status 422 is not available in all .NET runtimes (see https://github.com/dotnet/core/blob/4c4642d548074b3fbfd425541a968aadd75fea99/release-notes/2.1/Preview/api-diff/preview2/2.1-preview2_System.Net.md) + /// As the goal here is testing HTTP codes that are not in Chromium (see https://cs.chromium.org/chromium/src/net/http/http_status_code_list.h?sq=package:chromium&g=0) we will use code 426: Upgrade Required + /// + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.respond", "should work with status code 422")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldWorkReturnStatusPhrases() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.RespondAsync(new ResponseData + { + Status = HttpStatusCode.UpgradeRequired, Body = "Yo, page!" + }, 0)); + + var response = await Page.GoToAsync(TestConstants.EmptyPage); + Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.Status); + Assert.AreEqual("Upgrade Required", response.StatusText); + Assert.AreEqual("Yo, page!", await Page.EvaluateExpressionAsync("document.body.textContent")); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.respond", "should redirect")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldRedirect() + { + await Page.SetRequestInterceptionAsync(true); + + Page.AddRequestInterceptor(request => + { + if (!request.Url.Contains("rrredirect")) + { + return request.ContinueAsync(new Payload(), 0); + } + + return request.RespondAsync(new ResponseData + { + Status = HttpStatusCode.Redirect, + Headers = new Dictionary { ["location"] = TestConstants.EmptyPage } + }, 0); + }); + + var response = await Page.GoToAsync(TestConstants.ServerUrl + "/rrredirect"); + + Assert.That(response.Request.RedirectChain, Has.Exactly(1).Items); + Assert.AreEqual(TestConstants.ServerUrl + "/rrredirect", response.Request.RedirectChain[0].Url); + Assert.AreEqual(TestConstants.EmptyPage, response.Url); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.respond", "should allow mocking binary responses")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldAllowMockingBinaryResponses() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + var imageData = File.ReadAllBytes("./Assets/pptr.png"); + return request.RespondAsync(new ResponseData { ContentType = "image/png", BodyData = imageData }, 0); + }); + + await Page.EvaluateFunctionAsync(@"PREFIX => + { + const img = document.createElement('img'); + img.src = PREFIX + '/does-not-exist.png'; + document.body.appendChild(img); + return new Promise(fulfill => img.onload = fulfill); + }", TestConstants.ServerUrl); + var img = await Page.QuerySelectorAsync("img"); + Assert.True(ScreenshotHelper.PixelMatch("mock-binary-response.png", await img.ScreenshotDataAsync())); + } + + [PuppeteerTest("requestinterception-experimental.spec.ts", "Request.respond", + "should stringify intercepted request response headers")] + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldStringifyInterceptedRequestResponseHeaders() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => request.RespondAsync(new ResponseData + { + Status = HttpStatusCode.OK, + Headers = new Dictionary { ["foo"] = true }, + Body = "Yo, page!" + }, 0)); + + var response = await Page.GoToAsync(TestConstants.EmptyPage); + Assert.AreEqual(HttpStatusCode.OK, response.Status); + Assert.AreEqual("True", response.Headers["foo"]); + Assert.AreEqual("Yo, page!", await Page.EvaluateExpressionAsync("document.body.textContent")); + } + + [Skip(SkipAttribute.Targets.Firefox)] + public async Task ShouldAllowMultipleInterceptedRequestResponseHeaders() + { + await Page.SetRequestInterceptionAsync(true); + Page.AddRequestInterceptor(request => + { + return request.RespondAsync(new ResponseData + { + Status = HttpStatusCode.OK, + Headers = new Dictionary + { + ["foo"] = new [] { true, false }, + ["Set-Cookie"] = new [] { "sessionId=abcdef", "specialId=123456" } + }, + Body = "Yo, page!" + }, 0); + }); + + var response = await Page.GoToAsync(TestConstants.EmptyPage); + var cookies = await Page.GetCookiesAsync(TestConstants.EmptyPage); + + Assert.AreEqual(HttpStatusCode.OK, response.Status); + Assert.AreEqual("True\nFalse", response.Headers["foo"]); + Assert.AreEqual("Yo, page!", await Page.EvaluateExpressionAsync("document.body.textContent")); + Assert.AreEqual("specialId", cookies[0].Name); + Assert.AreEqual("123456", cookies[0].Value); + Assert.AreEqual("sessionId", cookies[1].Name); + Assert.AreEqual("abcdef", cookies[1].Value); + } +} diff --git a/lib/PuppeteerSharp.Tests/RequestInterceptionTests/SetRequestInterceptionTests.cs b/lib/PuppeteerSharp.Tests/RequestInterceptionTests/SetRequestInterceptionTests.cs index 4d055cdaf..db5693c64 100644 --- a/lib/PuppeteerSharp.Tests/RequestInterceptionTests/SetRequestInterceptionTests.cs +++ b/lib/PuppeteerSharp.Tests/RequestInterceptionTests/SetRequestInterceptionTests.cs @@ -15,10 +15,6 @@ namespace PuppeteerSharp.Tests.RequestInterceptionTests { public class SetRequestInterceptionTests : PuppeteerPageBaseTest { - public SetRequestInterceptionTests(): base() - { - } - [PuppeteerTest("requestinterception.spec.ts", "Page.setRequestInterception", "should intercept")] [Skip(SkipAttribute.Targets.Firefox)] public async Task ShouldIntercept() @@ -51,7 +47,7 @@ public async Task ShouldIntercept() [PuppeteerTest("requestinterception.spec.ts", "Page.setRequestInterception", "should work when POST is redirected with 302")] [Skip(SkipAttribute.Targets.Firefox)] - public async Task ShouldWorkWhenPostIsEedirectedWith302() + public async Task ShouldWorkWhenPostIsRedirectedWith302() { Server.SetRedirect("/rredirect", "/empty.html"); await Page.GoToAsync(TestConstants.EmptyPage); diff --git a/lib/PuppeteerSharp.Tests/Screenshots/golden-chromium/test.png b/lib/PuppeteerSharp.Tests/Screenshots/golden-chromium/test.png index 873db9a07e72b6757b7efb62b421af22c19430f0..f91bc4285be0cc5dda5f44bfbe4c0be0e91b6a71 100644 GIT binary patch literal 5867 zcmV700009a7bBm000ie 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 B7;^vs literal 33760 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 Q(4Um#)gBbf{_g+(0LUYe;Q#;t diff --git a/lib/PuppeteerSharp/BrowserData/Firefox.cs b/lib/PuppeteerSharp/BrowserData/Firefox.cs index 6a8e50464..64c64d51f 100644 --- a/lib/PuppeteerSharp/BrowserData/Firefox.cs +++ b/lib/PuppeteerSharp/BrowserData/Firefox.cs @@ -15,7 +15,7 @@ public static class Firefox /// public const string DefaultBuildId = "FIREFOX_NIGHTLY"; - private static readonly Dictionary _cachedBuildIds = new(); + private static readonly Dictionary _cachedBuildIds = []; internal static Task GetDefaultBuildIdAsync() => ResolveBuildIdAsync(DefaultBuildId); diff --git a/lib/PuppeteerSharp/IPage.cs b/lib/PuppeteerSharp/IPage.cs index 7f2a2fa7b..bfd81b49e 100644 --- a/lib/PuppeteerSharp/IPage.cs +++ b/lib/PuppeteerSharp/IPage.cs @@ -1098,8 +1098,8 @@ public interface IPage : IDisposable, IAsyncDisposable Task SetOfflineModeAsync(bool value); /// - /// Activating request interception enables request.AbortAsync, - /// request.ContinueAsync and request.RespondAsync methods. + /// Activating request interception enables request.AbortAsync, + /// request.ContinueAsync and request.RespondAsync methods. /// /// The request interception task. /// Whether to enable request interception.. @@ -1438,5 +1438,19 @@ public interface IPage : IDisposable, IAsyncDisposable /// Optional waiting parameters. /// A task that resolves after the page gets the prompt. Task WaitForDevicePromptAsync(WaitTimeoutOptions options = null); + + /// + /// , , and can accept an optional `priority` to activate Cooperative Intercept Mode. + /// In Cooperative Mode, all interception tasks are guaranteed to run and all async handlers are awaited. + /// The interception is resolved to the highest-priority resolution. + /// + /// Interception task. + void AddRequestInterceptor(Func interceptionTask); + + /// + /// Removes a previously added request interceptor. + /// + /// Interception task. + void RemoveRequestInterceptor(Func interceptionTask); } } diff --git a/lib/PuppeteerSharp/IRequest.cs b/lib/PuppeteerSharp/IRequest.cs index 7dffe63c2..7b7f363a0 100644 --- a/lib/PuppeteerSharp/IRequest.cs +++ b/lib/PuppeteerSharp/IRequest.cs @@ -105,6 +105,11 @@ public interface IRequest /// The redirect chain. IRequest[] RedirectChain { get; } + /// + /// Information about the request initiator. + /// + public Initiator Initiator { get; } + /// /// True when the request has POST data. Note that might still be null when this flag is true /// when the data is too long or not readily available in the decoded form. @@ -117,23 +122,29 @@ public interface IRequest /// If the URL is set it won't perform a redirect. The request will be silently forwarded to the new url. For example, the address bar will show the original url. /// /// Optional request overwrites. + /// Optional intercept abort priority. If provided, intercept will be resolved using cooperative handling rules. Otherwise, intercept will be resolved immediately. + /// IMPORTANT: If you set the priority, you will need to attach Request listener using instead of . /// Task. - Task ContinueAsync(Payload payloadOverrides = null); + Task ContinueAsync(Payload payloadOverrides = null, int? priority = null); /// /// Fulfills request with given response. To use this, request interception should be enabled with . Exception is thrown if request interception is not enabled. /// /// Response that will fulfill this request. + /// Optional intercept abort priority. If provided, intercept will be resolved using cooperative handling rules. Otherwise, intercept will be resolved immediately. + /// IMPORTANT: If you set the priority, you will need to attach Request listener using instead of . /// Task. - Task RespondAsync(ResponseData response); + Task RespondAsync(ResponseData response, int? priority = null); /// /// Aborts request. To use this, request interception should be enabled with . /// Exception is immediately thrown if the request interception is not enabled. /// /// Optional error code. Defaults to . + /// Optional intercept abort priority. If provided, intercept will be resolved using cooperative handling rules. Otherwise, intercept will be resolved immediately. + /// IMPORTANT: If you set the priority, you will need to attach Request listener using instead of . /// Task. - Task AbortAsync(RequestAbortErrorCode errorCode = RequestAbortErrorCode.Failed); + Task AbortAsync(RequestAbortErrorCode errorCode = RequestAbortErrorCode.Failed, int? priority = null); /// /// Fetches the POST data for the request from the browser. diff --git a/lib/PuppeteerSharp/Initiator.cs b/lib/PuppeteerSharp/Initiator.cs new file mode 100644 index 000000000..b6e92ec8d --- /dev/null +++ b/lib/PuppeteerSharp/Initiator.cs @@ -0,0 +1,54 @@ +// * MIT License +// * +// * Copyright (c) 2020 Darío Kondratiuk +// * +// * Permission is hereby granted, free of charge, to any person obtaining a copy +// * of this software and associated documentation files (the "Software"), to deal +// * in the Software without restriction, including without limitation the rights +// * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// * copies of the Software, and to permit persons to whom the Software is +// * furnished to do so, subject to the following conditions: +// * +// * The above copyright notice and this permission notice shall be included in all +// * copies or substantial portions of the Software. +// * +// * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// * SOFTWARE. + +namespace PuppeteerSharp; + +/// +/// Information about the request initiator. +/// +public class Initiator +{ + /// + /// Gets or sets the type of the initiator. + /// + public InitiatorType Type { get; set; } + + /// + /// Initiator URL, set for Parser type or for Script type (when script is importing module) or for SignedExchange type. + /// + public string Url { get; set; } + + /// + /// Initiator line number, set for Parser type or for Script type (when script is importing module) (0-based). + /// + public int? LineNumber { get; set; } + + /// + /// Initiator column number, set for Parser type or for Script type (when script is importing module) (0-based). + /// + public int? ColumnNumber { get; set; } + + /// + /// Set if another request triggered this request (e.g. preflight). + /// + public string RequestId { get; set; } +} diff --git a/lib/PuppeteerSharp/InitiatorType.cs b/lib/PuppeteerSharp/InitiatorType.cs new file mode 100644 index 000000000..f12aee3bd --- /dev/null +++ b/lib/PuppeteerSharp/InitiatorType.cs @@ -0,0 +1,40 @@ +using System.Runtime.Serialization; + +namespace PuppeteerSharp; + +/// +/// Type of the . +/// +public enum InitiatorType +{ + /// + /// Parser. + /// + Parser, + + /// + /// Script. + /// + Script, + + /// + /// Preload. + /// + Preload, + + /// + /// SignedExchange. + /// + [EnumMember(Value = "SignedExchange")] + SignedExchange, + + /// + /// Preflight. + /// + Preflight, + + /// + /// Other. + /// + Other, +} diff --git a/lib/PuppeteerSharp/InterceptResolutionAction.cs b/lib/PuppeteerSharp/InterceptResolutionAction.cs new file mode 100644 index 000000000..2b37b7009 --- /dev/null +++ b/lib/PuppeteerSharp/InterceptResolutionAction.cs @@ -0,0 +1,33 @@ +// * MIT License +// * +// * Copyright (c) 2020 Darío Kondratiuk +// * +// * Permission is hereby granted, free of charge, to any person obtaining a copy +// * of this software and associated documentation files (the "Software"), to deal +// * in the Software without restriction, including without limitation the rights +// * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// * copies of the Software, and to permit persons to whom the Software is +// * furnished to do so, subject to the following conditions: +// * +// * The above copyright notice and this permission notice shall be included in all +// * copies or substantial portions of the Software. +// * +// * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// * SOFTWARE. + +namespace PuppeteerSharp; + +internal enum InterceptResolutionAction +{ + Abort, + Respond, + Continue, + Disabled, + None, + AlreadyHandled, +} diff --git a/lib/PuppeteerSharp/InterceptResolutionState.cs b/lib/PuppeteerSharp/InterceptResolutionState.cs new file mode 100644 index 000000000..fbb5ad487 --- /dev/null +++ b/lib/PuppeteerSharp/InterceptResolutionState.cs @@ -0,0 +1,30 @@ +// * MIT License +// * +// * Copyright (c) 2020 Darío Kondratiuk +// * +// * Permission is hereby granted, free of charge, to any person obtaining a copy +// * of this software and associated documentation files (the "Software"), to deal +// * in the Software without restriction, including without limitation the rights +// * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// * copies of the Software, and to permit persons to whom the Software is +// * furnished to do so, subject to the following conditions: +// * +// * The above copyright notice and this permission notice shall be included in all +// * copies or substantial portions of the Software. +// * +// * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// * SOFTWARE. + +namespace PuppeteerSharp; + +internal class InterceptResolutionState(InterceptResolutionAction action, int? priority = null) +{ + public InterceptResolutionAction Action { get; set; } = action; + + public int? Priority { get; set; } = priority; +} diff --git a/lib/PuppeteerSharp/Messaging/RequestWillBeSentPayload.cs b/lib/PuppeteerSharp/Messaging/RequestWillBeSentPayload.cs index 57a705e1a..55e15b32b 100644 --- a/lib/PuppeteerSharp/Messaging/RequestWillBeSentPayload.cs +++ b/lib/PuppeteerSharp/Messaging/RequestWillBeSentPayload.cs @@ -1,3 +1,5 @@ +using PuppeteerSharp.Messaging.Protocol.Network; + namespace PuppeteerSharp.Messaging { internal class RequestWillBeSentPayload @@ -10,10 +12,12 @@ internal class RequestWillBeSentPayload public ResponsePayload RedirectResponse { get; set; } - public ResourceType Type { get; set; } + public ResourceType? Type { get; set; } public string FrameId { get; set; } public bool RedirectHasExtraInfo { get; set; } + + public Initiator Initiator { get; set; } } } diff --git a/lib/PuppeteerSharp/Messaging/StackTrace.cs b/lib/PuppeteerSharp/Messaging/StackTrace.cs index 11e9e9856..9e9dabc4d 100644 --- a/lib/PuppeteerSharp/Messaging/StackTrace.cs +++ b/lib/PuppeteerSharp/Messaging/StackTrace.cs @@ -2,6 +2,12 @@ namespace PuppeteerSharp.Messaging { internal class StackTrace { + public string Description { get; set; } + public ConsoleMessageLocation[] CallFrames { get; set; } + + public StackTrace Parent { get; set; } + + public string ParentId { get; set; } } } diff --git a/lib/PuppeteerSharp/NetworkManager.cs b/lib/PuppeteerSharp/NetworkManager.cs index a52980b80..33c47c8ae 100644 --- a/lib/PuppeteerSharp/NetworkManager.cs +++ b/lib/PuppeteerSharp/NetworkManager.cs @@ -242,10 +242,7 @@ private void EmitLoadingFailed(LoadingFailedEventResponse e) ForgetRequest(request, true); - RequestFailed?.Invoke(this, new RequestEventArgs - { - Request = request, - }); + RequestFailed?.Invoke(this, new RequestEventArgs(request)); } private void OnLoadingFinished(LoadingFinishedEventResponse e) @@ -274,10 +271,7 @@ private void EmitLoadingFinished(LoadingFinishedEventResponse e) ForgetRequest(request, true); - RequestFinished?.Invoke(this, new RequestEventArgs - { - Request = request, - }); + RequestFinished?.Invoke(this, new RequestEventArgs(request)); } private void ForgetRequest(Request request, bool events) @@ -335,10 +329,7 @@ private void EmitResponseEvent(ResponseReceivedResponse e, ResponseReceivedExtra request.Response = response; - Response?.Invoke(this, new ResponseCreatedEventArgs - { - Response = response, - }); + Response?.Invoke(this, new ResponseCreatedEventArgs(response)); } private async Task OnAuthRequiredAsync(FetchAuthRequiredResponse e) @@ -463,10 +454,16 @@ private async Task OnRequestAsync(RequestWillBeSentPayload e, string fetchReques _networkEventManager.StoreRequest(e.RequestId, request); - Request?.Invoke(this, new RequestEventArgs + Request?.Invoke(this, new RequestEventArgs(request)); + + try { - Request = request, - }); + await request.FinalizeInterceptionsAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to FinalizeInterceptionsAsync"); + } } private void OnRequestServedFromCache(RequestServedFromCacheResponse response) @@ -478,7 +475,7 @@ private void OnRequestServedFromCache(RequestServedFromCacheResponse response) request.FromMemoryCache = true; } - RequestServedFromCache?.Invoke(this, new RequestEventArgs { Request = request }); + RequestServedFromCache?.Invoke(this, new RequestEventArgs(request)); } private void HandleRequestRedirect(Request request, ResponsePayload responseMessage, ResponseReceivedExtraInfoResponse extraInfo) @@ -496,15 +493,8 @@ private void HandleRequestRedirect(Request request, ResponsePayload responseMess ForgetRequest(request, false); - Response?.Invoke(this, new ResponseCreatedEventArgs - { - Response = response, - }); - - RequestFinished?.Invoke(this, new RequestEventArgs - { - Request = request, - }); + Response?.Invoke(this, new ResponseCreatedEventArgs(response)); + RequestFinished?.Invoke(this, new RequestEventArgs(request)); } private async Task OnRequestWillBeSentAsync(RequestWillBeSentPayload e) diff --git a/lib/PuppeteerSharp/Page.cs b/lib/PuppeteerSharp/Page.cs index 8fd3ca59e..8a00d3357 100644 --- a/lib/PuppeteerSharp/Page.cs +++ b/lib/PuppeteerSharp/Page.cs @@ -60,6 +60,7 @@ public class Page : IPage private readonly TaskCompletionSource _closeCompletedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TimeoutSettings _timeoutSettings = new(); private readonly ConcurrentDictionary> _fileChooserInterceptors = new(); + private readonly ConcurrentSet> _requestInterceptionTask = []; private PageGetLayoutMetricsResponse _burstModeMetrics; private bool _screenshotBurstModeOn; private ScreenshotOptions _screenshotBurstModeOptions; @@ -1143,6 +1144,14 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + /// + public void AddRequestInterceptor(Func interceptionTask) + => _requestInterceptionTask.Add(interceptionTask); + + /// + public void RemoveRequestInterceptor(Func interceptionTask) + => _requestInterceptionTask.Remove(interceptionTask); + internal static async Task CreateAsync( CDPSession client, Target target, @@ -1253,7 +1262,7 @@ private async Task InitializeAsync() FrameManager.FrameDetached += (_, e) => FrameDetached?.Invoke(this, e); FrameManager.FrameNavigated += (_, e) => FrameNavigated?.Invoke(this, e); - networkManager.Request += (_, e) => Request?.Invoke(this, e); + networkManager.Request += (_, e) => OnRequest(e.Request); networkManager.RequestFailed += (_, e) => RequestFailed?.Invoke(this, e); networkManager.Response += (_, e) => Response?.Invoke(this, e); networkManager.RequestFinished += (_, e) => RequestFinished?.Invoke(this, e); @@ -1264,6 +1273,22 @@ await Task.WhenAll( Client.SendAsync("Log.enable")).ConfigureAwait(false); } + private void OnRequest(IRequest request) + { + if (request == null) + { + return; + } + + // Run tasks one after the other + foreach (var subscriber in _requestInterceptionTask) + { + (request as Request)?.EnqueueInterceptionAction(subscriber); + } + + Request?.Invoke(this, new RequestEventArgs(request)); + } + private async Task GoAsync(int delta, NavigationOptions options) { var history = await Client.SendAsync("Page.getNavigationHistory").ConfigureAwait(false); diff --git a/lib/PuppeteerSharp/Request.cs b/lib/PuppeteerSharp/Request.cs index 1b0f7fee3..53f87ae0c 100644 --- a/lib/PuppeteerSharp/Request.cs +++ b/lib/PuppeteerSharp/Request.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Net.Http; @@ -18,39 +19,41 @@ public class Request : IRequest private readonly CDPSession _client; private readonly bool _allowInterception; private readonly ILogger _logger; - private bool _interceptionHandled; + private readonly List> _interceptHandlers = []; + private Payload _continueRequestOverrides = new(); + private ResponseData _responseForRequest; + private RequestAbortErrorCode _abortErrorReason; + private InterceptResolutionState _interceptResolutionState = new(InterceptResolutionAction.None); internal Request( CDPSession client, - Frame frame, + IFrame frame, string interceptionId, bool allowInterception, - RequestWillBeSentPayload e, + RequestWillBeSentPayload data, List redirectChain) { _client = client; - _allowInterception = allowInterception; - _interceptionHandled = false; _logger = _client.Connection.LoggerFactory.CreateLogger(); - - RequestId = e.RequestId; + RequestId = data.RequestId; + IsNavigationRequest = data.RequestId == data.LoaderId && data.Type == ResourceType.Document; InterceptionId = interceptionId; - IsNavigationRequest = e.RequestId == e.LoaderId && e.Type == ResourceType.Document; - Url = e.Request.Url; - ResourceType = e.Type; - Method = e.Request.Method; - PostData = e.Request.PostData; - HasPostData = e.Request.HasPostData ?? false; + _allowInterception = allowInterception; + Url = data.Request.Url; + ResourceType = data.Type ?? ResourceType.Other; + Method = data.Request.Method; + PostData = data.Request.PostData; + HasPostData = data.Request.HasPostData ?? false; + Frame = frame; RedirectChainList = redirectChain; + Initiator = data.Initiator; Headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var keyValue in e.Request.Headers) + foreach (var keyValue in data.Request.Headers) { Headers[keyValue.Key] = keyValue.Value; } - - FromMemoryCache = false; } /// @@ -92,15 +95,74 @@ internal Request( /// public IRequest[] RedirectChain => RedirectChainList.ToArray(); - /// - public bool HasPostData { get; private set; } + /// + public Initiator Initiator { get; } - internal bool FromMemoryCache { get; set; } + /// + public bool HasPostData { get; } internal List RedirectChainList { get; } + internal Payload ContinueRequestOverrides + { + get + { + if (!_allowInterception) + { + throw new PuppeteerException("Request Interception is not enabled!"); + } + + return _continueRequestOverrides; + } + } + + internal ResponseData ResponseForRequest + { + get + { + if (!_allowInterception) + { + throw new PuppeteerException("Request Interception is not enabled!"); + } + + return _responseForRequest; + } + } + + internal RequestAbortErrorCode AbortErrorReason + { + get + { + if (!_allowInterception) + { + throw new PuppeteerException("Request Interception is not enabled!"); + } + + return _abortErrorReason; + } + } + + internal bool FromMemoryCache { get; set; } + + private InterceptResolutionState InterceptResolutionState + { + get + { + if (!_allowInterception) + { + return new InterceptResolutionState(InterceptResolutionAction.Disabled); + } + + return IsInterceptResolutionHandled + ? new InterceptResolutionState(InterceptResolutionAction.AlreadyHandled) + : _interceptResolutionState; + } + } + + private bool IsInterceptResolutionHandled { get; set; } + /// - public async Task ContinueAsync(Payload overrides = null) + public async Task ContinueAsync(Payload overrides = null, int? priority = null) { // Request interception is not supported for data: urls. if (Url.StartsWith("data:", StringComparison.InvariantCultureIgnoreCase)) @@ -113,12 +175,166 @@ public async Task ContinueAsync(Payload overrides = null) throw new PuppeteerException("Request Interception is not enabled!"); } - if (_interceptionHandled) + if (IsInterceptResolutionHandled) { throw new PuppeteerException("Request is already handled!"); } - _interceptionHandled = true; + if (priority is null) + { + await ContinueInternalAsync(overrides).ConfigureAwait(false); + return; + } + + _continueRequestOverrides = overrides; + + if (_interceptResolutionState.Priority is null || priority > _interceptResolutionState.Priority) + { + _interceptResolutionState = new InterceptResolutionState(InterceptResolutionAction.Continue, priority); + return; + } + + if (priority == _interceptResolutionState.Priority) + { + if (_interceptResolutionState.Action == InterceptResolutionAction.Abort || + _interceptResolutionState.Action == InterceptResolutionAction.Respond) + { + return; + } + + _interceptResolutionState.Action = InterceptResolutionAction.Continue; + } + } + + /// + public async Task RespondAsync(ResponseData response, int? priority = null) + { + if (Url.StartsWith("data:", StringComparison.Ordinal)) + { + return; + } + + if (!_allowInterception) + { + throw new PuppeteerException("Request Interception is not enabled!"); + } + + if (IsInterceptResolutionHandled) + { + throw new PuppeteerException("Request is already handled!"); + } + + if (priority is null) + { + Debug.Assert(response != null, nameof(response) + " != null"); + await RespondInternalAsync(response).ConfigureAwait(false); + } + + _responseForRequest = response; + + if (_interceptResolutionState.Priority is null || priority > _interceptResolutionState.Priority) + { + _interceptResolutionState = new InterceptResolutionState(InterceptResolutionAction.Respond, priority); + return; + } + + if (priority == _interceptResolutionState.Priority) + { + if (_interceptResolutionState.Action == InterceptResolutionAction.Abort) + { + return; + } + + _interceptResolutionState.Action = InterceptResolutionAction.Respond; + } + } + + /// + public async Task AbortAsync(RequestAbortErrorCode errorCode = RequestAbortErrorCode.Failed, int? priority = null) + { + // Request interception is not supported for data: urls. + if (Url.StartsWith("data:", StringComparison.InvariantCultureIgnoreCase)) + { + return; + } + + if (!_allowInterception) + { + throw new PuppeteerException("Request Interception is not enabled!"); + } + + if (IsInterceptResolutionHandled) + { + throw new PuppeteerException("Request is already handled!"); + } + + if (priority is null) + { + await AbortInternalAsync(errorCode).ConfigureAwait(false); + return; + } + + _abortErrorReason = errorCode; + + if (_interceptResolutionState.Priority is null || priority > _interceptResolutionState.Priority) + { + _interceptResolutionState = new InterceptResolutionState(InterceptResolutionAction.Abort, priority); + } + } + + /// + public async Task FetchPostDataAsync() + { + try + { + var result = await _client.SendAsync( + "Network.getRequestPostData", + new GetRequestPostDataRequest(RequestId)).ConfigureAwait(false); + return result.PostData; + } + catch (Exception ex) + { + _logger.LogError(ex, ex.ToString()); + } + + return null; + } + + internal async Task FinalizeInterceptionsAsync() + { + foreach (var handler in _interceptHandlers) + { + await handler(this).ConfigureAwait(false); + } + + switch (InterceptResolutionState.Action) + { + case InterceptResolutionAction.Abort: + await AbortAsync(_abortErrorReason).ConfigureAwait(false); + return; + case InterceptResolutionAction.Respond: + if (_responseForRequest is null) + { + throw new PuppeteerException("Response is missing for the interception"); + } + + await RespondAsync(_responseForRequest).ConfigureAwait(false); + return; + case InterceptResolutionAction.Continue: + await ContinueInternalAsync(_continueRequestOverrides).ConfigureAwait(false); + break; + } + } + + internal void EnqueueInterceptionAction(Func pendingHandler) + => _interceptHandlers.Add(pendingHandler); + + private Header[] HeadersArray(Dictionary headers) + => headers?.Select(pair => new Header { Name = pair.Key, Value = pair.Value }).ToArray(); + + private async Task ContinueInternalAsync(Payload overrides = null) + { + IsInterceptResolutionHandled = true; try { @@ -138,7 +354,7 @@ public async Task ContinueAsync(Payload overrides = null) if (overrides?.PostData != null) { - requestData.PostData = Convert.ToBase64String(Encoding.UTF8.GetBytes(overrides?.PostData)); + requestData.PostData = Convert.ToBase64String(Encoding.UTF8.GetBytes(overrides.PostData)); } if (overrides?.Headers?.Count > 0) @@ -150,31 +366,39 @@ public async Task ContinueAsync(Payload overrides = null) } catch (PuppeteerException ex) { + IsInterceptResolutionHandled = false; + // In certain cases, protocol will return error if the request was already canceled // or the page was closed. We should tolerate these errors _logger.LogError(ex.ToString()); } } - /// - public async Task RespondAsync(ResponseData response) + private async Task AbortInternalAsync(RequestAbortErrorCode errorCode) { - if (Url.StartsWith("data:", StringComparison.Ordinal)) - { - return; - } + var errorReason = errorCode.ToString(); + IsInterceptResolutionHandled = true; - if (!_allowInterception) + try { - throw new PuppeteerException("Request Interception is not enabled!"); + await _client.SendAsync("Fetch.failRequest", new FetchFailRequest + { + RequestId = InterceptionId, + ErrorReason = errorReason, + }).ConfigureAwait(false); } - - if (_interceptionHandled) + catch (PuppeteerException ex) { - throw new PuppeteerException("Request is already handled!"); + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors + _logger.LogError(ex.ToString()); + IsInterceptResolutionHandled = false; } + } - _interceptionHandled = true; + private async Task RespondInternalAsync(ResponseData response) + { + IsInterceptResolutionHandled = true; var responseHeaders = new List
(); @@ -211,53 +435,19 @@ public async Task RespondAsync(ResponseData response) responseHeaders.Add(new Header { Name = "content-type", Value = response.ContentType }); } - try + if (string.IsNullOrEmpty(InterceptionId)) { - await _client.SendAsync("Fetch.fulfillRequest", new FetchFulfillRequest - { - RequestId = InterceptionId, - ResponseCode = response.Status != null ? (int)response.Status : 200, - ResponseHeaders = responseHeaders.ToArray(), - Body = response.BodyData != null ? Convert.ToBase64String(response.BodyData) : null, - }).ConfigureAwait(false); - } - catch (PuppeteerException ex) - { - // In certain cases, protocol will return error if the request was already canceled - // or the page was closed. We should tolerate these errors - _logger.LogError(ex.ToString()); - } - } - - /// - public async Task AbortAsync(RequestAbortErrorCode errorCode = RequestAbortErrorCode.Failed) - { - // Request interception is not supported for data: urls. - if (Url.StartsWith("data:", StringComparison.InvariantCultureIgnoreCase)) - { - return; - } - - if (!_allowInterception) - { - throw new PuppeteerException("Request Interception is not enabled!"); - } - - if (_interceptionHandled) - { - throw new PuppeteerException("Request is already handled!"); + throw new PuppeteerException("HTTPRequest is missing _interceptionId needed for Fetch.fulfillRequest"); } - var errorReason = errorCode.ToString(); - - _interceptionHandled = true; - try { - await _client.SendAsync("Fetch.failRequest", new FetchFailRequest + await _client.SendAsync("Fetch.fulfillRequest", new FetchFulfillRequest { RequestId = InterceptionId, - ErrorReason = errorReason, + ResponseCode = response.Status != null ? (int)response.Status : 200, + ResponseHeaders = [.. responseHeaders], + Body = response.BodyData != null ? Convert.ToBase64String(response.BodyData) : null, }).ConfigureAwait(false); } catch (PuppeteerException ex) @@ -265,28 +455,8 @@ public async Task AbortAsync(RequestAbortErrorCode errorCode = RequestAbortError // In certain cases, protocol will return error if the request was already canceled // or the page was closed. We should tolerate these errors _logger.LogError(ex.ToString()); + IsInterceptResolutionHandled = false; } } - - /// - public async Task FetchPostDataAsync() - { - try - { - var result = await _client.SendAsync( - "Network.getRequestPostData", - new GetRequestPostDataRequest(RequestId)).ConfigureAwait(false); - return result.PostData; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - } - - return null; - } - - private Header[] HeadersArray(Dictionary headers) - => headers?.Select(pair => new Header { Name = pair.Key, Value = pair.Value }).ToArray(); } } diff --git a/lib/PuppeteerSharp/RequestAbortErrorCode.cs b/lib/PuppeteerSharp/RequestAbortErrorCode.cs index cac5b3cfb..1e9d1f57f 100644 --- a/lib/PuppeteerSharp/RequestAbortErrorCode.cs +++ b/lib/PuppeteerSharp/RequestAbortErrorCode.cs @@ -1,7 +1,7 @@ namespace PuppeteerSharp { /// - /// Abort error codes. used by . + /// Abort error codes. used by . /// public enum RequestAbortErrorCode { diff --git a/lib/PuppeteerSharp/RequestEventArgs.cs b/lib/PuppeteerSharp/RequestEventArgs.cs index b03ad8ecb..bd84b121f 100644 --- a/lib/PuppeteerSharp/RequestEventArgs.cs +++ b/lib/PuppeteerSharp/RequestEventArgs.cs @@ -8,12 +8,12 @@ namespace PuppeteerSharp /// /// /// - public class RequestEventArgs : EventArgs + public class RequestEventArgs(IRequest request) : EventArgs { /// /// Gets the request. /// /// The request. - public IRequest Request { get; internal set; } + public IRequest Request { get; } = request; } } diff --git a/lib/PuppeteerSharp/ResponseCreatedEventArgs.cs b/lib/PuppeteerSharp/ResponseCreatedEventArgs.cs index 17b431da7..981f8daf9 100644 --- a/lib/PuppeteerSharp/ResponseCreatedEventArgs.cs +++ b/lib/PuppeteerSharp/ResponseCreatedEventArgs.cs @@ -5,12 +5,12 @@ namespace PuppeteerSharp /// /// arguments. /// - public class ResponseCreatedEventArgs : EventArgs + public class ResponseCreatedEventArgs(IResponse response) : EventArgs { /// /// Gets the response. /// /// The response. - public IResponse Response { get; internal set; } + public IResponse Response { get; } = response; } } diff --git a/lib/PuppeteerSharp/ResponseData.cs b/lib/PuppeteerSharp/ResponseData.cs index fa72dc3ad..9e34915bf 100644 --- a/lib/PuppeteerSharp/ResponseData.cs +++ b/lib/PuppeteerSharp/ResponseData.cs @@ -7,7 +7,7 @@ namespace PuppeteerSharp /// /// Response that will fulfill a request. /// - public struct ResponseData + public class ResponseData { /// /// Response body (text content). diff --git a/lib/PuppeteerSharp/TaskManager.cs b/lib/PuppeteerSharp/TaskManager.cs index 49e5beff3..d697f3c95 100644 --- a/lib/PuppeteerSharp/TaskManager.cs +++ b/lib/PuppeteerSharp/TaskManager.cs @@ -16,7 +16,7 @@ internal void RerunAll() { foreach (var waitTask in WaitTasks) { - _ = waitTask.Rerun(); + _ = waitTask.RerunAsync(); } } diff --git a/lib/PuppeteerSharp/WaitTask.cs b/lib/PuppeteerSharp/WaitTask.cs index e43ebc77e..3be4e77c6 100644 --- a/lib/PuppeteerSharp/WaitTask.cs +++ b/lib/PuppeteerSharp/WaitTask.cs @@ -58,7 +58,7 @@ internal WaitTask( TaskScheduler.Default); } - _ = Rerun(); + _ = RerunAsync(); } internal Task Task => _result.Task; @@ -80,7 +80,7 @@ public void Dispose() _isDisposed = true; } - internal async Task Rerun() + internal async Task RerunAsync() { try { @@ -179,7 +179,7 @@ await poller.EvaluateFunctionAsync(@"async poller => { await poller.stop(); }").ConfigureAwait(false); - poller = null; + _poller = null; } catch (Exception) { @@ -221,6 +221,13 @@ private Exception GetBadException(Exception exception) return null; } + // This is a different message coming from Firefox in the same situation. + // This is not upstream. + if (exception.Message.Contains("Could not find object with given id")) + { + return null; + } + return exception; }