using Microsoft.Playwright; const string defaultSelector = "button:has-text('Click to')"; var targetUrl = PromptForTargetUrl(); var options = StressOptions.Parse(args, defaultSelector, targetUrl); Console.WriteLine($"URL: {options.TargetUrl}"); Console.WriteLine($"Instances: {options.InstanceCount}"); Console.WriteLine($"Interval: {options.IntervalMs}ms"); Console.WriteLine($"Selector: {options.ButtonSelector}"); Console.WriteLine($"Headless: {options.Headless}"); Console.WriteLine("Press Ctrl+C to stop."); using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; cts.Cancel(); }; var workers = Enumerable.Range(1, options.InstanceCount) .Select(instanceId => RunWorkerAsync(instanceId, options, cts.Token)) .ToArray(); await Task.WhenAll(workers); static string PromptForTargetUrl() { while (true) { Console.Write("Enter target URL: "); var input = Console.ReadLine(); if (!string.IsNullOrWhiteSpace(input) && Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) { return uri.ToString(); } Console.WriteLine("Invalid URL. Please enter a full http/https URL."); } } static async Task RunWorkerAsync(int instanceId, StressOptions options, CancellationToken cancellationToken) { try { using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = options.Headless }); var page = await browser.NewPageAsync(); await page.GotoAsync(options.TargetUrl, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30000 }); Console.WriteLine($"[{instanceId}] connected"); var clickCount = 0; while (!cancellationToken.IsCancellationRequested) { try { // HTMX can replace the button after each request, so resolve it fresh every loop. var button = page.Locator(options.ButtonSelector).First; await button.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 5000 }); await button.ClickAsync(new LocatorClickOptions { Timeout = 5000, Force = true }); clickCount += 1; if (clickCount % 25 == 0) { Console.WriteLine($"[{instanceId}] clicks sent: {clickCount}"); } } catch (TimeoutException) { Console.WriteLine($"[{instanceId}] click timeout"); } catch (PlaywrightException ex) { Console.WriteLine($"[{instanceId}] click error: {ex.Message}"); } try { await Task.Delay(options.IntervalMs, cancellationToken); } catch (OperationCanceledException) { break; } } await page.CloseAsync(); Console.WriteLine($"[{instanceId}] stopped"); } catch (OperationCanceledException) { Console.WriteLine($"[{instanceId}] canceled"); } catch (Exception ex) { Console.WriteLine($"[{instanceId}] fatal: {ex.Message}"); if (ex.Message.Contains("Executable doesn't exist", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("Install browser binaries first:"); Console.WriteLine("./bin/Debug/net10.0/.playwright/node/linux-x64/node ./bin/Debug/net10.0/.playwright/package/cli.js install chromium"); } } } internal sealed record StressOptions( string TargetUrl, int InstanceCount, int IntervalMs, string ButtonSelector, bool Headless) { public static StressOptions Parse(string[] args, string defaultSelector, string targetUrl) { var instanceCount = ParsePositiveInt(Environment.GetEnvironmentVariable("INSTANCE_COUNT"), 20); var intervalMs = ParsePositiveInt(Environment.GetEnvironmentVariable("CLICK_INTERVAL_MS"), 200); var buttonSelector = Environment.GetEnvironmentVariable("BUTTON_SELECTOR") ?? defaultSelector; var headless = ParseBool(Environment.GetEnvironmentVariable("HEADLESS"), true); foreach (var arg in args) { var split = arg.Split('=', 2, StringSplitOptions.TrimEntries); if (split.Length != 2) { continue; } var key = split[0].TrimStart('-', '/').ToLowerInvariant(); var value = split[1]; switch (key) { case "instances": case "instancecount": instanceCount = ParsePositiveInt(value, instanceCount); break; case "interval": case "intervalms": intervalMs = ParsePositiveInt(value, intervalMs); break; case "selector": case "buttonselector": buttonSelector = value; break; case "headless": headless = ParseBool(value, headless); break; } } return new StressOptions(targetUrl, instanceCount, intervalMs, buttonSelector, headless); } private static int ParsePositiveInt(string? rawValue, int fallback) { if (!string.IsNullOrWhiteSpace(rawValue) && int.TryParse(rawValue, out var parsed) && parsed > 0) { return parsed; } return fallback; } private static bool ParseBool(string? rawValue, bool fallback) { if (!string.IsNullOrWhiteSpace(rawValue) && bool.TryParse(rawValue, out var parsed)) { return parsed; } return fallback; } }