using AngleSharp.Dom;
using XFESystem.Desktop;
namespace XFESystem.Browser.Tests;
public sealed class BrowserPipelineTests
{
[Fact]
public void OrdinaryScriptErrorDoesNotSuppressFollowingIndependentScripts()
{
var source = new Resources();source.Add("/","text/html","<p id='P'>before</p><script>throw new Error('statement budget is only text');</script><script>document.getElementById('P').textContent='after'</script>");
var page = new BrowserService(source).Navigate("http://test/");
Assert.Contains("after",page.Text,StringComparison.Ordinal);Assert.Contains("JavaScript error:",page.Text,StringComparison.Ordinal);
}
[Fact]
public void BudgetFailureStopsAutomaticDeferredScriptsUntilNextNavigation()
{
var source = new Resources();source.Add("/","text/html","<p id='P'>before</p><script>while(true){}</script><script defer src='/After.js'></script>");
source.Add("/After.js","application/javascript","document.getElementById('P').textContent='after'");
var page = new BrowserService(source,new BrowserLimits{MaximumStatements=100}).Navigate("http://test/");
Assert.Contains("before",page.Text,StringComparison.Ordinal);Assert.DoesNotContain("after",page.Text,StringComparison.Ordinal);Assert.Single(source.Requests);
}
[Fact]
public void StylesExternalClassicDeferDynamicScriptsAndLifecycleUseOneRealDocument()
{
var source = new Resources();
source.Add("/Index", "text/html", """
<title>Pipeline</title><link rel="stylesheet" href="Theme.css"><div id="Box">before</div>
<script>globalThis.order=[];document.addEventListener('DOMContentLoaded',()=>order.push('dom'));window.addEventListener('load',()=>order.push('load'));</script>
<script src="First.js"></script><script defer src="Defer.js"></script><script>order.push('inline');</script>
""");
source.Add("/First.js", "application/javascript", "order.push('first');document.querySelector('#Box').textContent='external worked';const s=document.createElement('script');s.src='Dynamic.js';s.onload=()=>order.push('dynamic-load');document.head.appendChild(s);");
source.Add("/Dynamic.js", "text/javascript", "order.push('dynamic');");
source.Add("/Defer.js", "application/javascript", "order.push('defer');");
source.Add("/Theme.css", "text/css", "#Box{width:200px;height:50px;color:#123456;background:#aabbcc;margin:0}");
var browser = new BrowserService(source);
var page = browser.Navigate("http://test/Index");
Assert.Contains("external worked", page.Text, StringComparison.Ordinal);
Assert.Equal("first,dynamic,dynamic-load,inline,defer,dom,load", browser.ExecuteJavaScript("order.join(',')"));
var layout = browser.Layout(new(600, 400));
var box = layout.Boxes.Single(b => b.Element?.Id == "Box" && b.Node is IElement);
Assert.Equal(200, box.ContentBox.Width); Assert.Equal(50, box.ContentBox.Height);
Assert.Contains(layout.Commands, c => c.Kind == BrowserDrawKind.Fill && c.Color == 0xAABBCC);
Assert.Equal(4, browser.LoadedResources.Count);
}
[Fact]
public void PointerBubblesToParentAndKeyboardEditsFocusedTextareaWithInputEvents()
{
var resources = new Resources(); resources.Add("/", "text/html", """
<style>body{margin:0}textarea{display:block;width:200px;height:40px;margin:0;padding:0;border:0}</style>
<div id="Parent"><textarea id="Entry"></textarea></div><p id="Output"></p><script>
globalThis.events=[];Parent.addEventListener('click',()=>events.push('parent'));
Entry.addEventListener('input',()=>Output.textContent=Entry.value);
</script>
""".Replace("Parent.addEventListener", "document.getElementById('Parent').addEventListener").Replace("Entry.addEventListener", "document.getElementById('Entry').addEventListener").Replace("Output.textContent=Entry.value", "document.getElementById('Output').textContent=document.getElementById('Entry').value"));
var browser = new BrowserService(resources); browser.Navigate("http://test/"); browser.Layout(new(600,400));
browser.Pointer(new("mousedown", 10, 10, Buttons:1)); browser.Pointer(new("mouseup",10,10));
browser.Key(new("keydown", "a", "KeyA", "a")); browser.Key(new("keyup", "a", "KeyA"));
Assert.Equal("a", browser.ExecuteJavaScript("document.getElementById('Entry').value"));
Assert.Equal("parent", browser.ExecuteJavaScript("events.join(',')"));
Assert.Contains("a", browser.RefreshDocument().Text, StringComparison.Ordinal);
browser.Key(new("keydown","Backspace"));
Assert.Equal("", browser.ExecuteJavaScript("document.getElementById('Entry').value"));
}
[Fact]
public void PreventDefaultStopsTypingAndWheelDefaultAction()
{
var resources = new Resources(); resources.Add("/", "text/html", "<textarea style='width:200px;height:50px' id='T'></textarea><script>document.getElementById('T').addEventListener('keydown',e=>e.preventDefault());window.addEventListener('wheel',e=>e.preventDefault());document.getElementById('T').focus()</script>");
var browser = new BrowserService(resources); browser.Navigate("http://test/");
browser.Key(new("keydown", "a", "KeyA", "a"));
Assert.Equal("",browser.ExecuteJavaScript("document.getElementById('T').value"));
Assert.True(browser.Pointer(new("wheel",10,10,DeltaY:60)));
}
[Fact]
public void TimersAndRafRequireExplicitMonotonicPump()
{
var resources = new Resources(); resources.Add("/", "text/html", "<p id='P'>before</p><script>setTimeout(()=>document.getElementById('P').textContent='timer',20);requestAnimationFrame(t=>document.title='frame '+t);</script>");
var browser = new BrowserService(resources); browser.Navigate("http://test/");
Assert.DoesNotContain("timer", browser.CurrentPage.Text, StringComparison.Ordinal);
browser.AdvanceTime(20);
Assert.Contains("timer", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.StartsWith("frame ", browser.CurrentPage.Title, StringComparison.Ordinal);
}
[Theory]
[InlineData("http://test/Unsafe.js",true,"HTTP subresource")]
[InlineData("https://test/Unsafe.js",false,"certificate validation")]
public void HttpsSubresourcesCannotBypassMixedContentOrCertificateChecks(string script, bool validated,string error)
{
var source = new Resources(); source.Add("/", "text/html", "<script src='"+script+"'></script>"); source.Add("/Unsafe.js", "application/javascript", "document.title='unsafe'",validated);
var browser = new BrowserService(source); var page = browser.Navigate("https://test/");
Assert.Contains(error, page.Text,StringComparison.Ordinal); Assert.NotEqual("unsafe",page.Title);
}
[Fact]
public void DeclaredCspBlocksExternalStyleAndScriptRequestsAndInlineStyles()
{
var source = new Resources(); source.Add("/", "text/html", "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'\"><link rel='stylesheet' href='/X.css'><div id='X' style='width:200px;background:#ff0000'>safe</div><script src='/X.js'></script>");
var browser = new BrowserService(source); browser.Navigate("http://test/");
var list = browser.Layout(new(600,400));
Assert.Single(source.Requests); Assert.Empty(browser.LoadedResources);
Assert.DoesNotContain(list.Commands,c=>c.Color==0xFF0000);
}
[Fact]
public void PageSessionsDoNotShareGlobalsOrDom()
{
var browser = new BrowserService(); var first=(BrowserService)browser.CreatePageSession();var second=(BrowserService)browser.CreatePageSession();
first.Navigate("about:Start");second.Navigate("about:Start");first.ExecuteJavaScript("globalThis.privateValue=123;document.title='first'");
Assert.Equal("undefined",second.ExecuteJavaScript("typeof privateValue"));Assert.NotEqual(first.RefreshDocument().Title,second.RefreshDocument().Title);
}
[Fact]
public void PackagedMonacoCoreIsTheVerifiedTransformationWithoutHostPathAccess()
{
var resources = new PackagedBrowserResources();
Assert.True(resources.TryGet(new(PackagedBrowserResources.MonacoCoreRoot+"EditorCore.js"),out var response));
var bytes = System.Text.Encoding.UTF8.GetBytes(response.Body);
Assert.Equal(2_293_601, bytes.Length);
Assert.Equal("828c22be163ab40c177106ef4d4a0ff86ae3611bafc9dd82ccef678e08971952",
Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(bytes)));
Assert.Equal("application/javascript",response.ContentType);
Assert.False(resources.TryGet(new(PackagedBrowserResources.Origin+"/Browser/Monaco/0.52.2/min/vs/editor/editor.main.js"),out _));
Assert.False(resources.TryGet(new("file:///C:/Secret"),out _));
}
private sealed class Resources : IWebTransport
{
private readonly Dictionary<string,(string Mime,string Body,bool Validated)> values=[];
public List<string> Requests {get;}=[];
public void Add(string path,string mime,string body,bool validated=true)=>values[path]=(mime,body,validated);
public WebResponse Get(Uri address,BrowserLimits limits)
{ Requests.Add(address.AbsoluteUri);var value=values[address.AbsolutePath];return new(address,200,value.Mime,value.Body,value.Validated); }
}
}
using AngleSharp.Dom;
using XFESystem.Desktop;
namespace XFESystem.Browser.Tests;
public sealed class BrowserPipelineTests
{
[Fact]
public void OrdinaryScriptErrorDoesNotSuppressFollowingIndependentScripts()
{
var source = new Resources();source.Add("/","text/html","<p id='P'>before</p><script>throw new Error('statement budget is only text');</script><script>document.getElementById('P').textContent='after'</script>");
var page = new BrowserService(source).Navigate("http://test/");
Assert.Contains("after",page.Text,StringComparison.Ordinal);Assert.Contains("JavaScript error:",page.Text,StringComparison.Ordinal);
}
[Fact]
public void BudgetFailureStopsAutomaticDeferredScriptsUntilNextNavigation()
{
var source = new Resources();source.Add("/","text/html","<p id='P'>before</p><script>while(true){}</script><script defer src='/After.js'></script>");
source.Add("/After.js","application/javascript","document.getElementById('P').textContent='after'");
var page = new BrowserService(source,new BrowserLimits{MaximumStatements=100}).Navigate("http://test/");
Assert.Contains("before",page.Text,StringComparison.Ordinal);Assert.DoesNotContain("after",page.Text,StringComparison.Ordinal);Assert.Single(source.Requests);
}
[Fact]
public void StylesExternalClassicDeferDynamicScriptsAndLifecycleUseOneRealDocument()
{
var source = new Resources();
source.Add("/Index", "text/html", """
<title>Pipeline</title><link rel="stylesheet" href="Theme.css"><div id="Box">before</div>
<script>globalThis.order=[];document.addEventListener('DOMContentLoaded',()=>order.push('dom'));window.addEventListener('load',()=>order.push('load'));</script>
<script src="First.js"></script><script defer src="Defer.js"></script><script>order.push('inline');</script>
""");
source.Add("/First.js", "application/javascript", "order.push('first');document.querySelector('#Box').textContent='external worked';const s=document.createElement('script');s.src='Dynamic.js';s.onload=()=>order.push('dynamic-load');document.head.appendChild(s);");
source.Add("/Dynamic.js", "text/javascript", "order.push('dynamic');");
source.Add("/Defer.js", "application/javascript", "order.push('defer');");
source.Add("/Theme.css", "text/css", "#Box{width:200px;height:50px;color:#123456;background:#aabbcc;margin:0}");
var browser = new BrowserService(source);
var page = browser.Navigate("http://test/Index");
Assert.Contains("external worked", page.Text, StringComparison.Ordinal);
Assert.Equal("first,dynamic,dynamic-load,inline,defer,dom,load", browser.ExecuteJavaScript("order.join(',')"));
var layout = browser.Layout(new(600, 400));
var box = layout.Boxes.Single(b => b.Element?.Id == "Box" && b.Node is IElement);
Assert.Equal(200, box.ContentBox.Width); Assert.Equal(50, box.ContentBox.Height);
Assert.Contains(layout.Commands, c => c.Kind == BrowserDrawKind.Fill && c.Color == 0xAABBCC);
Assert.Equal(4, browser.LoadedResources.Count);
}
[Fact]
public void PointerBubblesToParentAndKeyboardEditsFocusedTextareaWithInputEvents()
{
var resources = new Resources(); resources.Add("/", "text/html", """
<style>body{margin:0}textarea{display:block;width:200px;height:40px;margin:0;padding:0;border:0}</style>
<div id="Parent"><textarea id="Entry"></textarea></div><p id="Output"></p><script>
globalThis.events=[];Parent.addEventListener('click',()=>events.push('parent'));
Entry.addEventListener('input',()=>Output.textContent=Entry.value);
</script>
""".Replace("Parent.addEventListener", "document.getElementById('Parent').addEventListener").Replace("Entry.addEventListener", "document.getElementById('Entry').addEventListener").Replace("Output.textContent=Entry.value", "document.getElementById('Output').textContent=document.getElementById('Entry').value"));
var browser = new BrowserService(resources); browser.Navigate("http://test/"); browser.Layout(new(600,400));
browser.Pointer(new("mousedown", 10, 10, Buttons:1)); browser.Pointer(new("mouseup",10,10));
browser.Key(new("keydown", "a", "KeyA", "a")); browser.Key(new("keyup", "a", "KeyA"));
Assert.Equal("a", browser.ExecuteJavaScript("document.getElementById('Entry').value"));
Assert.Equal("parent", browser.ExecuteJavaScript("events.join(',')"));
Assert.Contains("a", browser.RefreshDocument().Text, StringComparison.Ordinal);
browser.Key(new("keydown","Backspace"));
Assert.Equal("", browser.ExecuteJavaScript("document.getElementById('Entry').value"));
}
[Fact]
public void PreventDefaultStopsTypingAndWheelDefaultAction()
{
var resources = new Resources(); resources.Add("/", "text/html", "<textarea style='width:200px;height:50px' id='T'></textarea><script>document.getElementById('T').addEventListener('keydown',e=>e.preventDefault());window.addEventListener('wheel',e=>e.preventDefault());document.getElementById('T').focus()</script>");
var browser = new BrowserService(resources); browser.Navigate("http://test/");
browser.Key(new("keydown", "a", "KeyA", "a"));
Assert.Equal("",browser.ExecuteJavaScript("document.getElementById('T').value"));
Assert.True(browser.Pointer(new("wheel",10,10,DeltaY:60)));
}
[Fact]
public void TimersAndRafRequireExplicitMonotonicPump()
{
var resources = new Resources(); resources.Add("/", "text/html", "<p id='P'>before</p><script>setTimeout(()=>document.getElementById('P').textContent='timer',20);requestAnimationFrame(t=>document.title='frame '+t);</script>");
var browser = new BrowserService(resources); browser.Navigate("http://test/");
Assert.DoesNotContain("timer", browser.CurrentPage.Text, StringComparison.Ordinal);
browser.AdvanceTime(20);
Assert.Contains("timer", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.StartsWith("frame ", browser.CurrentPage.Title, StringComparison.Ordinal);
}
[Theory]
[InlineData("http://test/Unsafe.js",true,"HTTP subresource")]
[InlineData("https://test/Unsafe.js",false,"certificate validation")]
public void HttpsSubresourcesCannotBypassMixedContentOrCertificateChecks(string script, bool validated,string error)
{
var source = new Resources(); source.Add("/", "text/html", "<script src='"+script+"'></script>"); source.Add("/Unsafe.js", "application/javascript", "document.title='unsafe'",validated);
var browser = new BrowserService(source); var page = browser.Navigate("https://test/");
Assert.Contains(error, page.Text,StringComparison.Ordinal); Assert.NotEqual("unsafe",page.Title);
}
[Fact]
public void DeclaredCspBlocksExternalStyleAndScriptRequestsAndInlineStyles()
{
var source = new Resources(); source.Add("/", "text/html", "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'\"><link rel='stylesheet' href='/X.css'><div id='X' style='width:200px;background:#ff0000'>safe</div><script src='/X.js'></script>");
var browser = new BrowserService(source); browser.Navigate("http://test/");
var list = browser.Layout(new(600,400));
Assert.Single(source.Requests); Assert.Empty(browser.LoadedResources);
Assert.DoesNotContain(list.Commands,c=>c.Color==0xFF0000);
}
[Fact]
public void PageSessionsDoNotShareGlobalsOrDom()
{
var browser = new BrowserService(); var first=(BrowserService)browser.CreatePageSession();var second=(BrowserService)browser.CreatePageSession();
first.Navigate("about:Start");second.Navigate("about:Start");first.ExecuteJavaScript("globalThis.privateValue=123;document.title='first'");
Assert.Equal("undefined",second.ExecuteJavaScript("typeof privateValue"));Assert.NotEqual(first.RefreshDocument().Title,second.RefreshDocument().Title);
}
[Fact]
public void PackagedMonacoCoreIsTheVerifiedTransformationWithoutHostPathAccess()
{
var resources = new PackagedBrowserResources();
Assert.True(resources.TryGet(new(PackagedBrowserResources.MonacoCoreRoot+"EditorCore.js"),out var response));
var bytes = System.Text.Encoding.UTF8.GetBytes(response.Body);
Assert.Equal(2_293_601, bytes.Length);
Assert.Equal("828c22be163ab40c177106ef4d4a0ff86ae3611bafc9dd82ccef678e08971952",
Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(bytes)));
Assert.Equal("application/javascript",response.ContentType);
Assert.False(resources.TryGet(new(PackagedBrowserResources.Origin+"/Browser/Monaco/0.52.2/min/vs/editor/editor.main.js"),out _));
Assert.False(resources.TryGet(new("file:///C:/Secret"),out _));
}
private sealed class Resources : IWebTransport
{
private readonly Dictionary<string,(string Mime,string Body,bool Validated)> values=[];
public List<string> Requests {get;}=[];
public void Add(string path,string mime,string body,bool validated=true)=>values[path]=(mime,body,validated);
public WebResponse Get(Uri address,BrowserLimits limits)
{ Requests.Add(address.AbsoluteUri);var value=values[address.AbsolutePath];return new(address,200,value.Mime,value.Body,value.Validated); }
}
}