From da784e9e76e113e31a6c75dd07711b17390e827c Mon Sep 17 00:00:00 2001 From: User Date: Wed, 11 Mar 2026 17:42:29 +0200 Subject: [PATCH] Add playwright parallelism --- NBomber.WebBrowser.sln | 16 ++ examples/Demo/Playwright/PlaywrightExample.cs | 68 +++---- .../Controllers/HomeController.cs | 19 ++ examples/HttpSimulator/HttpSimulator.csproj | 9 + examples/HttpSimulator/Program.cs | 31 ++++ .../HttpSimulator/Views/Home/Index.cshtml | 14 ++ .../appsettings.Development.json | 8 + examples/HttpSimulator/appsettings.json | 9 + examples/HttpSimulator/wwwroot/favicon.ico | Bin 0 -> 5430 bytes .../NBomber.WebBrowser.csproj | 3 +- .../Playwright/PlaywrightBrowserPool.cs | 171 ++++++++++++++++++ 11 files changed, 305 insertions(+), 43 deletions(-) create mode 100644 examples/HttpSimulator/Controllers/HomeController.cs create mode 100644 examples/HttpSimulator/HttpSimulator.csproj create mode 100644 examples/HttpSimulator/Program.cs create mode 100644 examples/HttpSimulator/Views/Home/Index.cshtml create mode 100644 examples/HttpSimulator/appsettings.Development.json create mode 100644 examples/HttpSimulator/appsettings.json create mode 100644 examples/HttpSimulator/wwwroot/favicon.ico create mode 100644 src/NBomber.WebBrowser/Playwright/PlaywrightBrowserPool.cs diff --git a/NBomber.WebBrowser.sln b/NBomber.WebBrowser.sln index d852929..19071ab 100644 --- a/NBomber.WebBrowser.sln +++ b/NBomber.WebBrowser.sln @@ -1,11 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36930.0 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NBomber.WebBrowser", "src\NBomber.WebBrowser\NBomber.WebBrowser.csproj", "{73B37EAF-90E5-465F-97DB-874FD956A82C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo", "examples\Demo\Demo.csproj", "{A652A3E0-6518-495A-825C-32C3718C8647}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{EE2BE11B-2872-4E3A-8B7F-FEED1174D5EE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpSimulator", "examples\HttpSimulator\HttpSimulator.csproj", "{B3DCE2C8-0011-42DB-8DD0-B5FA9E4A3034}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,8 +25,19 @@ Global {A652A3E0-6518-495A-825C-32C3718C8647}.Debug|Any CPU.Build.0 = Debug|Any CPU {A652A3E0-6518-495A-825C-32C3718C8647}.Release|Any CPU.ActiveCfg = Release|Any CPU {A652A3E0-6518-495A-825C-32C3718C8647}.Release|Any CPU.Build.0 = Release|Any CPU + {B3DCE2C8-0011-42DB-8DD0-B5FA9E4A3034}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3DCE2C8-0011-42DB-8DD0-B5FA9E4A3034}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3DCE2C8-0011-42DB-8DD0-B5FA9E4A3034}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3DCE2C8-0011-42DB-8DD0-B5FA9E4A3034}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {A652A3E0-6518-495A-825C-32C3718C8647} = {EE2BE11B-2872-4E3A-8B7F-FEED1174D5EE} + {B3DCE2C8-0011-42DB-8DD0-B5FA9E4A3034} = {EE2BE11B-2872-4E3A-8B7F-FEED1174D5EE} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {56B32A8A-8F09-4654-9750-60CE054B88D3} EndGlobalSection EndGlobal diff --git a/examples/Demo/Playwright/PlaywrightExample.cs b/examples/Demo/Playwright/PlaywrightExample.cs index 4345fb4..04896bf 100644 --- a/examples/Demo/Playwright/PlaywrightExample.cs +++ b/examples/Demo/Playwright/PlaywrightExample.cs @@ -4,62 +4,46 @@ namespace Demo.Playwright; -using Microsoft.Playwright; - public class PlaywrightExample { public static async Task Run() { - // downloading the Chrome var installedBrowser = await new BrowserFetcher(SupportedBrowser.Chrome).DownloadAsync(BrowserTag.Stable); var browserPath = installedBrowser.GetExecutablePath(); - - using var playwright = await Playwright.CreateAsync(); - - await using var browser = await playwright.Chromium.LaunchAsync( - new BrowserTypeLaunchOptions - { - Headless = true, - ExecutablePath = browserPath - } - ); + + await using var playwrightBrowserPool = new PlaywrightBrowserPool(); + + // Set the target number of concurrent virtual users + var targetVirtualUsers = 3; var scenario = Scenario.Create("playwright_scenario", async context => { - var page = await browser.NewPageAsync(); - - await Step.Run("open nbomber", context, async () => - { - var pageResponse = await page.GotoAsync("https://nbomber.com/"); - - var html = await page.ContentAsync(); - var totalSize = await page.GetDataTransferSize(); - - return Response.Ok(sizeBytes: totalSize); - }); + // Get a browser context from the pool + // This ensures each virtual user has its own isolated context + var browserContext = playwrightBrowserPool.GetBrowserContext(context.ScenarioInfo.InstanceNumber); + var page = await browserContext.NewPageAsync(); - await Step.Run("open bing", context, async () => + try { - var pageResponse = await page.GotoAsync("https://www.bing.com/maps"); - - await page.WaitForSelectorAsync(".searchbox input"); - await page.FocusAsync(".searchbox input"); - await page.Keyboard.TypeAsync("CN Tower, Toronto, Ontario, Canada"); - - await page.Keyboard.PressAsync("Enter"); - await page.WaitForLoadStateAsync(LoadState.Load); - - var totalSize = await page.GetDataTransferSize(); - return Response.Ok(sizeBytes: totalSize); - }); + await Step.Run("open local website", context, async () => + { + await page.GotoAsync("http://localhost:5280"); + return Response.Ok(); + }); - await page.CloseAsync(); - - return Response.Ok(); + return Response.Ok(); + } + finally + { + // Ensure page is closed even if there's an exception + // The browser context remains alive and will be reused + await page.CloseAsync(); + } }) - .WithWarmUpDuration(TimeSpan.FromSeconds(3)) + .WithInit(async context => await playwrightBrowserPool.Initialize(targetVirtualUsers, browserPath)) + .WithWarmUpDuration(TimeSpan.FromSeconds(5)) .WithLoadSimulations( - Simulation.KeepConstant(1, TimeSpan.FromSeconds(30)) + Simulation.KeepConstant(targetVirtualUsers, TimeSpan.FromSeconds(30)) ); NBomberRunner diff --git a/examples/HttpSimulator/Controllers/HomeController.cs b/examples/HttpSimulator/Controllers/HomeController.cs new file mode 100644 index 0000000..dc7696b --- /dev/null +++ b/examples/HttpSimulator/Controllers/HomeController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace HttpSimulator.Controllers +{ + public class HomeController : Controller + { + private readonly ILogger _logger; + + public HomeController(ILogger logger) + { + _logger = logger; + } + + public IActionResult Index() + { + return View(); + } + } +} diff --git a/examples/HttpSimulator/HttpSimulator.csproj b/examples/HttpSimulator/HttpSimulator.csproj new file mode 100644 index 0000000..6568b3d --- /dev/null +++ b/examples/HttpSimulator/HttpSimulator.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/examples/HttpSimulator/Program.cs b/examples/HttpSimulator/Program.cs new file mode 100644 index 0000000..94c6bb9 --- /dev/null +++ b/examples/HttpSimulator/Program.cs @@ -0,0 +1,31 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllersWithViews(); + +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.MaxConcurrentConnections = 1000; + options.Limits.MaxConcurrentUpgradedConnections = 1000; +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); +} +app.UseRouting(); + +app.UseAuthorization(); + +app.MapStaticAssets(); + +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}") + .WithStaticAssets(); + + +app.Run(); diff --git a/examples/HttpSimulator/Views/Home/Index.cshtml b/examples/HttpSimulator/Views/Home/Index.cshtml new file mode 100644 index 0000000..6386810 --- /dev/null +++ b/examples/HttpSimulator/Views/Home/Index.cshtml @@ -0,0 +1,14 @@ +@{ + Layout = null; +} + + + + + + Hello World + + +

Hello World

+ + diff --git a/examples/HttpSimulator/appsettings.Development.json b/examples/HttpSimulator/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/examples/HttpSimulator/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/HttpSimulator/appsettings.json b/examples/HttpSimulator/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/examples/HttpSimulator/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/HttpSimulator/wwwroot/favicon.ico b/examples/HttpSimulator/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..63e859b476eff5055e0e557aaa151ca8223fbeef GIT binary patch literal 5430 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 - + + diff --git a/src/NBomber.WebBrowser/Playwright/PlaywrightBrowserPool.cs b/src/NBomber.WebBrowser/Playwright/PlaywrightBrowserPool.cs new file mode 100644 index 0000000..932bbf4 --- /dev/null +++ b/src/NBomber.WebBrowser/Playwright/PlaywrightBrowserPool.cs @@ -0,0 +1,171 @@ +using Microsoft.Playwright; + +namespace NBomber.WebBrowser.Playwright; + +/// +/// Manages a pool of Playwright browser contexts for parallel load testing with NBomber. +/// Creates multiple Playwright instances and browsers to avoid CDP (Chrome DevTools Protocol) serialization bottleneck. +/// +/// +/// +/// await using var pool = new PlaywrightBrowserPool(); +/// await pool.Initialize(virtualUsers: 100, browserPath: "/path/to/chrome"); +/// var context = pool.GetBrowserContext(instanceNumber); +/// +/// +public class PlaywrightBrowserPool : IAsyncDisposable +{ + private readonly List _playwrightInstances = []; + private readonly List _browsers = []; + private ClientPool? _contextPool; + + /// + /// Gets a browser context from the pool for the specified virtual user instance. + /// + /// The virtual user instance number (from ScenarioInfo.InstanceNumber). + /// An isolated browser context for the virtual user. + /// Thrown when pool is not initialized. + public IBrowserContext GetBrowserContext(int instanceNumber) + { + if (_contextPool == null) + throw new InvalidOperationException("Pool not initialized. Call InitializeAsync first."); + + return _contextPool.GetClient(instanceNumber); + } + + /// + /// Initializes the browser pool with the specified configuration. + /// Creates Playwright instances, browsers, and contexts based on the number of virtual users. + /// Uses a 1:3 ratio of Playwright instances to browsers for optimal performance. + /// + /// Number of concurrent virtual users to support. + /// Path to the Chrome/Chromium executable. + /// Run browsers in headless mode. Default is true. + /// Maximum browser contexts per browser instance. Default is 50. + public async Task Initialize( + int virtualUsers, + string browserPath, + bool isHeadless = true, + int maxContextsPerBrowser = 50) + { + // Clear previous instances if any + _playwrightInstances.Clear(); + _browsers.Clear(); + + var contextPool = new ClientPool(); + + var (playwrightInstanceCount, browsersPerInstance, contextsPerBrowser) = CalculateInstancesCount(virtualUsers, maxContextsPerBrowser); + + _contextPool = contextPool; + + for (var i = 0; i < playwrightInstanceCount; i++) + { + var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + _playwrightInstances.Add(playwright); + + for (var b = 0; b < browsersPerInstance; b++) + { + var browser = await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions + { + Headless = isHeadless, + ExecutablePath = browserPath, + Args = new[] + { + "--no-sandbox", // Disables Chrome sandbox; required in Docker/CI, speeds up startup + "--disable-setuid-sandbox", // Disables SUID sandbox (Linux only) + "--disable-dev-shm-usage", // Uses /tmp instead of /dev/shm; critical for high load in containers + "--disable-gpu", // Disables GPU acceleration; not needed for load testing + "--disable-extensions" // Disables extensions; reduces per-browser overhead + } + } + ); + _browsers.Add(browser); + + for (var j = 0; j < contextsPerBrowser; j++) + { + var browserContext = await browser.NewContextAsync(); + contextPool.AddClient(browserContext); + } + } + } + } + + /// + /// Disposes all browser contexts, browsers, and Playwright instances. + /// Called automatically when using 'await using' pattern. + /// + public async ValueTask DisposeAsync() + { + if (_contextPool != null) + { + foreach (var browserContext in _contextPool.Clients) + { + try + { + await browserContext.CloseAsync(); + } + catch + { + // Context may already be closed if browser was closed + } + } + } + + foreach (var browser in _browsers) + { + try + { + await browser.CloseAsync(); + await browser.DisposeAsync(); + } + catch + { + // Browser may already be closed + } + } + + foreach (var playwright in _playwrightInstances) + { + playwright.Dispose(); + } + + _browsers.Clear(); + _playwrightInstances.Clear(); + _contextPool = null; + } + + private (int playwrightInstanceCount, int browsersPerInstance, int contextsPerBrowser) CalculateInstancesCount( + int virtualUsers, + int maxContextsPerBrowser) + { + if (virtualUsers <= 0) + throw new ArgumentException("Virtual users must be greater than 0", nameof(virtualUsers)); + + // Step 1: Calculate total browsers needed based on contexts per browser + var totalBrowsersNeeded = Math.Max(1, (int)Math.Ceiling((double)virtualUsers / maxContextsPerBrowser)); + + // Step 2: Calculate Playwright instances based on browsers (optimal ratio 1:3) + var playwrightInstanceCount = CalculateOptimalPlaywrightInstanceCount(totalBrowsersNeeded); + + // Step 3: Distribute browsers evenly across Playwright instances + var browsersPerInstance = Math.Max(1, (int)Math.Ceiling((double)totalBrowsersNeeded / playwrightInstanceCount)); + + // Step 4: Calculate contexts per browser + var totalBrowsers = playwrightInstanceCount * browsersPerInstance; + var contextsPerBrowser = (int)Math.Ceiling((double)virtualUsers / totalBrowsers); + + return (playwrightInstanceCount, browsersPerInstance, contextsPerBrowser); + } + + private int CalculateOptimalPlaywrightInstanceCount(int browsersNeeded) + { + // Strategy: Each Playwright instance manages 2-4 browsers + const int browsersPerInstance = 3; + + var instancesNeeded = (int)Math.Ceiling((double)browsersNeeded / browsersPerInstance); + + // Cap at 6 instances max (diminishing returns beyond this) + return Math.Min(6, Math.Max(1, instancesNeeded)); + } +}