/*
* Copyright (c) 2026 Proton AG
*
* This file is part of ProtonVPN.
*
* ProtonVPN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonVPN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonVPN. If not, see .
*/
using System;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FlaUI.Core.Tools;
using NUnit.Framework;
using ProtonVPN.Common.Core.Extensions;
namespace ProtonVPN.UI.Tests.TestsHelper;
public class BrowserUtils
{
private const int CHROME_PORT = 9222;
private const int EDGE_PORT = 9223;
private const string CHROME_PATH = @"C:\Program Files\Google\Chrome\Application\chrome.exe";
private const string EDGE_PATH = @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe";
private const string WEB_RTC_SCRIPT = """
new Promise((resolve) => {
const candidates = [];
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.createDataChannel('');
pc.createOffer().then(o => pc.setLocalDescription(o));
pc.onicecandidate = e => {
if (!e.candidate) { pc.close(); resolve(candidates); return; }
candidates.push(e.candidate.candidate);
};
setTimeout(() => { pc.close(); resolve(candidates); }, 5000);
})
""";
public static void KillAllBrowsers()
{
Thread.Sleep(TestConstants.OneSecondTimeout);
foreach (string? name in new[] { "chrome", "msedge" })
{
foreach (Process process in Process.GetProcessesByName(name))
{
try
{
process.Kill();
process.WaitForExit(TestConstants.FiveSecondsTimeout);
}
catch
{
process.Kill(true);
process.WaitForExit(TestConstants.FiveSecondsTimeout);
}
}
}
Thread.Sleep(TestConstants.FiveSecondsTimeout);
}
public static void VerifyWebRtcNotLeaking(string browserApp, string vpnIp)
{
string publicIp = GetBrowserWebRtcIpWithRetry(browserApp);
Assert.That(publicIp, Is.EqualTo(vpnIp).Or.Contains("No internet"),
$"WebRTC leak detected in {browserApp}!" +
$"\nExposed IP: {publicIp}" +
$"\nExpected VPN IP: {vpnIp}");
}
public static void VerifyBrowserIpWithRetry(string browserApp, bool hasVpn, string? ipAddressToCompare)
{
string? browserIp = null;
RetryResult retry = Retry.WhileEmpty(
() =>
{
browserIp = GetBrowserIpWithRetry(browserApp);
return browserIp;
},
TestConstants.ThirtySecondsTimeout, TestConstants.ApiRetryInterval);
if (retry.Success)
{
Assert.That((browserIp == ipAddressToCompare) == hasVpn,
$"Expected {browserApp} to have VPN {hasVpn.ToOnOffString()}" +
$"\n{browserApp} has IP: {browserIp}" +
$"\nVPN App has IP: {ipAddressToCompare}");
}
}
public static void AssertBrowserInternetAvailability(string browserApp, bool shouldBeAvailable)
{
string? browserIp = null;
RetryResult retry = Retry.WhileEmpty(
() =>
{
browserIp = GetBrowserIpWithRetry(browserApp);
return browserIp;
},
TestConstants.ThirtySecondsTimeout, TestConstants.ApiRetryInterval);
if (retry.Success)
{
if (shouldBeAvailable)
{
Assert.That(browserIp, Does.Match(@"\b\d{1,3}(\.\d{1,3}){3}\b"), "Expected internet to be available.");
}
else
{
Assert.That(browserIp, Does.Contain("No internet").Or.Contain("Your Internet access is blocked").Or.Contain("This site can’t be reached").Or.Contain("Press space to play"), "Expected internet to not be available.");
}
}
}
public static void AssertBrowserCanLoadDuckDuckGo(string browserApp)
{
AssertBrowserLoadsUrl(browserApp, "https://duckduckgo.com/", "DuckDuckGo");
}
public static void OpenStreamingWebsite(string browserApp)
{
AssertBrowserLoadsUrl(browserApp, "https://abc.com/watch-live", "ABC Live Stream");
}
private static void AssertBrowserLoadsUrl(string browserApp, string url, string expectedTitle)
{
RetryResult retry = Retry.WhileEmpty(
() => GetBrowserPageTitleWithRetry(browserApp, url),
TestConstants.OneMinuteTimeout, TestConstants.ApiRetryInterval);
Assert.That(retry.Success, Is.True, $"{expectedTitle} did not load within timeout.");
Assert.That(retry.Result, Does.Contain(expectedTitle), $"Expected {expectedTitle} page title, got: {retry.Result}");
}
private static string GetBrowserIpWithRetry(string browserApp)
{
// This method connects to the Browser via CDP and gets the IP that the Browser sees
// It uses https://api.ipify.org instead of http://ip-api.com/json, because the Browser forces HTTPS via HSTS, and ip-api.com does not support HTTPS on the free tier
string url = "https://api.ipify.org";
return ExecuteWithBrowserRetry(browserApp,
port => ExecuteScriptInBrowserAsync(port, url, "document.body.innerText.trim()"));
}
private static string GetBrowserWebRtcIpWithRetry(string browserApp)
{
return ExecuteWithBrowserRetry(browserApp,
port => ExecuteWebRtcScriptInBrowserAsync(port));
}
private static string GetBrowserPageTitleWithRetry(string browserApp, string url)
{
return ExecuteWithBrowserRetry(browserApp,
port => ExecuteScriptInBrowserAsync(port, url, "document.title"));
}
private static string ExecuteWithBrowserRetry(
string browserApp,
Func> operation)
{
(string Path, int DebugPort) browserConfig = GetBrowserConfig(browserApp);
RetryResult retry = Retry.WhileEmpty(
() =>
{
StartBrowserWithCDP(browserConfig.Path, browserConfig.DebugPort);
return operation(browserConfig.DebugPort).Result ?? string.Empty;
},
TestConstants.OneMinuteTimeout, TestConstants.ApiRetryInterval, ignoreException: true);
return retry.Result ?? "No internet";
}
private static (string Path, int DebugPort) GetBrowserConfig(string browserApp)
{
switch (browserApp)
{
case "Google Chrome":
return (CHROME_PATH, CHROME_PORT);
case "Edge":
return (EDGE_PATH, EDGE_PORT);
default:
throw new ArgumentException($"Unknown browser: {browserApp}");
}
}
private static void StartBrowserWithCDP(string browserPath, int debugPort)
{
Process.Start(new ProcessStartInfo
{
FileName = browserPath,
Arguments = $"--remote-debugging-port={debugPort} --headless about:blank"
});
}
private static async Task ExecuteScriptInBrowserAsync(int debugPort, string url, string expression)
{
await Task.Delay(TestConstants.TwoSecondsTimeout);
using ClientWebSocket ws = await ConnectToBrowserAsync(debugPort);
await NavigateToUrlAsync(ws, url);
await Task.Delay(TestConstants.TwoSecondsTimeout);
JsonElement evalResult = await EvaluateExpressionAsync(ws, expression, awaitPromise: false);
return ExtractStringResult(evalResult);
}
private static async Task ExecuteWebRtcScriptInBrowserAsync(int debugPort)
{
await Task.Delay(TestConstants.TwoSecondsTimeout);
using ClientWebSocket ws = await ConnectToBrowserAsync(debugPort);
JsonElement evalResult = await EvaluateExpressionAsync(ws, WEB_RTC_SCRIPT, awaitPromise: true);
return ExtractWebRtcIp(evalResult);
}
private static async Task ConnectToBrowserAsync(int debugPort)
{
using HttpClient http = new HttpClient();
string json = await http.GetStringAsync($"http://localhost:{debugPort}/json");
JsonElement tabs = JsonSerializer.Deserialize(json);
string wsUrl = FindPageWebSocketUrl(tabs);
ClientWebSocket ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri(wsUrl), CancellationToken.None);
return ws;
}
private static string FindPageWebSocketUrl(JsonElement tabs)
{
foreach (JsonElement tab in tabs.EnumerateArray())
{
if (tab.GetProperty("type").GetString() == "page")
{
return tab.GetProperty("webSocketDebuggerUrl").GetString() ?? "";
}
}
throw new InvalidOperationException("No page tab found in browser");
}
private static async Task SendCommandAsync(ClientWebSocket ws, object command)
{
string msg = JsonSerializer.Serialize(command);
await ws.SendAsync(
Encoding.UTF8.GetBytes(msg),
WebSocketMessageType.Text,
true,
CancellationToken.None);
byte[] buffer = new byte[4096];
WebSocketReceiveResult result = await ws.ReceiveAsync(buffer, CancellationToken.None);
return JsonSerializer.Deserialize(Encoding.UTF8.GetString(buffer, 0, result.Count));
}
private static async Task NavigateToUrlAsync(ClientWebSocket ws, string url)
{
await SendCommandAsync(ws, new
{
id = 1,
method = "Page.navigate",
@params = new { url }
});
}
private static async Task EvaluateExpressionAsync(ClientWebSocket ws, string expression, bool awaitPromise)
{
return await SendCommandAsync(ws, new
{
id = 2,
method = "Runtime.evaluate",
@params = new { expression, awaitPromise, returnByValue = awaitPromise }
});
}
private static string ExtractStringResult(JsonElement evalResult)
{
return evalResult
.GetProperty("result")
.GetProperty("result")
.GetProperty("value")
.GetString() ?? "unknown";
}
private static string ExtractWebRtcIp(JsonElement evalResult)
{
JsonElement rawResult = evalResult
.GetProperty("result")
.GetProperty("result")
.GetProperty("value");
string candidates = string.Join("\n", rawResult.EnumerateArray().Select(c => c.GetString() ?? ""));
Match match = Regex.Match(candidates, @"udp \d+ (\d{1,3}(?:\.\d{1,3}){3}) \d+ typ srflx");
return match.Groups[1].Value;
}
}