using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace XFESystem.Browser.Tests;
public sealed class BrowserDomTests
{
[Fact]
public void WrappersPreserveNodeIdentityAndActualTreeMutations()
{
var page = new Page();
page.Run("""
const p=document.getElementById('Parent'), a=document.getElementById('Child');
const n=document.createElement('span');n.id='Added';n.textContent='Hello';p.insertBefore(n,a);
const fragment=document.createDocumentFragment();fragment.append(document.createTextNode(' tail'),document.createElement('b'));p.appendChild(fragment);
if(p.children.item(0)!==n||document.querySelector('#Added')!==n||n.ownerDocument!==document)throw Error('identity');
if(!(n instanceof HTMLElement)||!(document instanceof Document)||fragment.childNodes.length!==0)throw Error('types');
a.remove();n.setAttribute('data-example','yes');
""");
Assert.Equal("Hello tail", page.Document.GetElementById("Parent")!.TextContent);
Assert.Null(page.Document.GetElementById("Child"));
Assert.Equal("yes", page.Document.GetElementById("Added")!.GetAttribute("data-example"));
}
[Fact]
public void EventCaptureTargetBubbleAndIdentityFollowActualAncestors()
{
var page = new Page();
page.Run("""
var trace=[];const p=document.getElementById('Parent'),c=document.getElementById('Child');
window.addEventListener('click',e=>trace.push('window:'+e.eventPhase),true);
document.addEventListener('click',e=>trace.push('document:'+e.eventPhase),true);
p.addEventListener('click',e=>trace.push('parent:'+e.eventPhase),true);
c.addEventListener('click',e=>trace.push('capture:'+e.eventPhase),true);
c.addEventListener('click',e=>{if(e.target!==c||e.currentTarget!==c)throw Error('target');trace.push('target:'+e.eventPhase);});
p.addEventListener('click',e=>trace.push('bubble:'+e.eventPhase));
var event=new MouseEvent('click',{bubbles:true,cancelable:true});c.dispatchEvent(event);
if(event.currentTarget!==null||event.eventPhase!==0)throw Error('cleanup');
""");
Assert.Equal("window:1,document:1,parent:1,capture:2,target:2,bubble:3", page.Value("trace.join(',')"));
}
[Fact]
public void ListenerRemovalOnceCancelableAndPassiveHaveRealEffects()
{
var page = new Page();
page.Run("""
var count=0;const c=document.getElementById('Child');function gone(){count+=100;}
c.addEventListener('test',gone);c.removeEventListener('test',gone);
c.addEventListener('test',()=>++count,{once:true});
c.addEventListener('test',e=>e.preventDefault(),{passive:true});
var first=new Event('test',{cancelable:true});if(!c.dispatchEvent(first)||first.defaultPrevented)throw Error('passive');
c.dispatchEvent(new Event('test'));c.addEventListener('test',e=>e.preventDefault());
var second=new Event('test',{cancelable:true});if(c.dispatchEvent(second)||!second.defaultPrevented)throw Error('cancel');
""");
Assert.Equal("1", page.Value("count"));
}
[Fact]
public void StopPropagationAndImmediatePropagationHaveDistinctSemantics()
{
var page = new Page();
page.Run("""
var calls=[];const p=document.getElementById('Parent'),c=document.getElementById('Child');
c.addEventListener('one',e=>{calls.push(1);e.stopPropagation();});c.addEventListener('one',()=>calls.push(2));p.addEventListener('one',()=>calls.push(3));
c.dispatchEvent(new Event('one',{bubbles:true}));
c.addEventListener('two',e=>{calls.push(4);e.stopImmediatePropagation();});c.addEventListener('two',()=>calls.push(5));
c.dispatchEvent(new Event('two',{bubbles:true}));
""");
Assert.Equal("1,2,4", page.Value("calls.join(',')"));
}
[Fact]
public void NativeDispatchUsesWebKeyboardCoordinatesAndReturnsCancellation()
{
var page = new Page();
page.Run("var seen='';document.getElementById('Child').addEventListener('keydown',e=>{seen=e.key+':'+e.ctrlKey+':'+e.keyCode;e.preventDefault();});");
Assert.False(page.Dom.DispatchEvent(page.Document.GetElementById("Child"), "keydown", new() { Key = "s", Code = "KeyS", CtrlKey = true }));
Assert.Equal("s:true:83", page.Value("seen"));
Assert.Same(page.Document.GetElementById("Child"), page.Dom.ResolveNode(page.Dom.NodeIdentity(page.Document.GetElementById("Child")!)));
}
[Fact]
public void ClassListAndStyleMutateActualAttributesAndNotifyLayout()
{
var changes = 0;
var page = new Page(host: new() { Mutated = () => ++changes });
page.Run("""
var c=document.getElementById('Child');c.classList.add('a','b');c.classList.toggle('a',false);c.classList.replace('b','c');
c.style.backgroundColor='#123456';c.style.setProperty('width','120px');c.style.removeProperty('width');
if(!c.classList.contains('c')||c.classList.length!==1||c.style.backgroundColor!=='#123456')throw Error('style');
""");
Assert.Equal("c", page.Document.GetElementById("Child")!.ClassName);
Assert.Equal("background-color:#123456", page.Document.GetElementById("Child")!.GetAttribute("style"));
Assert.True(changes >= 5);
}
[Fact]
public void LayoutGettersComeFromInjectedMeasuredLayout()
{
var page = new Page(host: new() { Measure = _ => new(12, 25, 180, 44), ComputedStyle = (_, property) => property == "color" ? "rgb(1, 2, 3)" : null, Viewport = () => new(1280, 720) });
Assert.Equal("12:69:180:1280:rgb(1, 2, 3)", page.Value("const c=document.getElementById('Child'),r=c.getBoundingClientRect();[r.x,r.bottom,c.clientWidth,innerWidth,getComputedStyle(c).color].join(':')"));
}
[Fact]
public void AddedScriptAndLinkNotifyResourceOwnerButInnerHtmlScriptStaysInert()
{
var resources = new List<IElement>();
var page = new Page(host: new() { ResourceRequested = resources.Add });
page.Run("var s=document.createElement('script');s.src='Code.js';document.head.appendChild(s);var l=document.createElement('link');l.href='Style.css';l.rel='stylesheet';document.head.append(l);document.body.innerHTML+='<script src=\"Inert.js\"></script>';");
Assert.Equal(new[] { "script", "link" }, resources.Select(element => element.LocalName));
Assert.Equal("https://example.test/Docs/Code.js", page.Value("s.src"));
}
[Fact]
public void MutationObserverRunsAsAMicrotaskAndDisconnects()
{
var page = new Page();
page.Run("""
var records=[];var observer=new MutationObserver(batch=>records.push(...batch.map(x=>x.type+':'+(x.attributeName||''))));
observer.observe(document.body,{subtree:true,attributes:true,childList:true});
document.getElementById('Child').setAttribute('title','New');document.body.append(document.createElement('aside'));
""");
Assert.Equal("attributes:title,childList:", page.Value("records.join(',')"));
page.Run("observer.disconnect();document.body.append(document.createElement('section'));");
Assert.Equal("2", page.Value("records.length"));
}
[Fact]
public void CompleteLoadingFiresActualLifecycleOnce()
{
var page = new Page();
page.Run("var states=[];document.addEventListener('readystatechange',()=>states.push(document.readyState));document.addEventListener('DOMContentLoaded',()=>states.push('dom'));window.addEventListener('load',()=>states.push('load'));");
page.Dom.CompleteLoading(); page.Dom.CompleteLoading();
Assert.Equal("interactive,dom,complete,load", page.Value("states.join(',')"));
}
[Fact]
public void FailedInnerHtmlPreservesOriginalWrappedNodeIdentity()
{
var page = new Page(new() { MaximumDomNodes = 12 });
page.Run("var original=document.getElementById('Child');");
Assert.False(page.Runtime.Evaluate("document.getElementById('Parent').innerHTML='<span>x</span>'.repeat(50)").Success);
Assert.Equal("true", page.Value("original===document.getElementById('Child')"));
}
[Fact]
public void ExplicitBridgeDoesNotExposeClrReflection()
{
var page = new Page();
Assert.Equal("undefined:undefined:undefined", page.Value("[typeof document.GetType,typeof document.body.GetType,typeof System].join(':')"));
}
[Fact]
public void DomInitializationAndHostCallbacksPreserveSmallStatementBudget()
{
var page = new Page(new() { MaximumStatements = 100 });
Assert.False(page.Runtime.Evaluate("while(true){document.getElementById('Child').classList.contains('x');}").Success);
Assert.Equal("42", page.Value("6*7"));
}
[Fact]
public void EditableTextareaDefaultInputUsesSelectionAndActualValue()
{
var page = new Page();
page.Run("var area=document.createElement('textarea');area.id='Area';area.value='abcdef';document.body.append(area);area.setSelectionRange(2,4);var inputs=[];area.addEventListener('input',e=>inputs.push(e.inputType+':'+e.data));");
page.Dom.FocusNode(page.Document.GetElementById("Area"));
Assert.True(page.Dom.ApplyTextInput("XY")); Assert.Equal("abXYef", page.Value("area.value"));
Assert.Equal("4:4", page.Value("area.selectionStart+':'+area.selectionEnd"));
Assert.True(page.Dom.ApplyTextInput("", -1)); Assert.Equal("abXef", page.Value("area.value"));
Assert.Equal("insertText:XY,deleteContentBackward:", page.Value("inputs.join(',')"));
page.Run("area.readOnly=true;"); Assert.False(page.Dom.ApplyTextInput("blocked"));
}
[Fact]
public void RangeReplacementPreservesSelectionAndCombinedControlBudget()
{
var page = new Page(new() { MaximumHtmlCharacters = 200 });
page.Run("var area=document.createElement('textarea');document.body.append(area);area.value='abcdef';area.setSelectionRange(4,6);area.setRangeText('XY',0,2,'preserve');");
Assert.Equal("XYcdef:4:6", page.Value("[area.value,area.selectionStart,area.selectionEnd].join(':')"));
Assert.False(page.Runtime.Evaluate("area.value='x'.repeat(198)").Success);
Assert.Equal("XYcdef", page.Value("area.value"));
}
[Fact]
public void CommentAndFailedCloneCannotBypassOrPoisonDomBudget()
{
var page = new Page(new() { MaximumHtmlCharacters = 160 });
Assert.False(page.Runtime.Evaluate("document.createComment('x'.repeat(160))").Success);
page.Run("document.getElementById('Child').textContent='Still works';");
Assert.Contains("Still works", page.Document.Body!.TextContent);
}
[Fact]
public void ResizeObserverReportsRealMeasuredChangesAndStopsWhenIdle()
{
var width = 120d; var page = new Page(host: new() { Measure = _ => new(10, 20, width, 40) });
page.Run("var sizes=[];var observer=new ResizeObserver(entries=>sizes.push(entries[0].contentRect.width));observer.observe(document.getElementById('Parent'));");
Assert.True(page.Runtime.Pump(1).Success); Assert.Equal("120", page.Value("sizes.join(',')")); Assert.Equal(0, page.Runtime.PendingTaskCount);
page.Dom.NotifyLayoutChanged(); page.Runtime.Pump(2); Assert.Equal("120", page.Value("sizes.join(',')"));
width = 240; page.Dom.NotifyLayoutChanged(); page.Runtime.Pump(3); Assert.Equal("120,240", page.Value("sizes.join(',')"));
page.Run("observer.disconnect()"); width = 300; page.Dom.NotifyLayoutChanged(); page.Runtime.Pump(4);
Assert.Equal("120,240", page.Value("sizes.join(',')")); Assert.Equal(0, page.Runtime.PendingTaskCount);
}
[Fact]
public void ChildCollectionsStayLiveAcrossRealMutations()
{
var page = new Page();
Assert.Equal("1:2:true", page.Value("const p=document.getElementById('Parent'),c=p.children,b=document.getElementsByTagName('button'),before=c.length;const n=document.createElement('button');p.append(n);[before,c.length,b.item(1)===n].join(':')"));
}
[Fact]
public void PointerCoordinatesAndKeyboardCharacterCodesMatchViewportAndEventType()
{
var page = new Page(host: new() { Viewport = () => new(800, 600, 100, 200), Measure = _ => new(10, 20, 50, 30) });
page.Run("var pos='';document.getElementById('Child').addEventListener('pointerdown',e=>pos=[e.clientX,e.pageX,e.pageY,e.offsetX,e.offsetY,e.pointerType].join(':'));");
page.Dom.DispatchEvent(page.Document.GetElementById("Child"), "pointerdown", new() { ClientX = 25, ClientY = 35 });
Assert.Equal("25:125:235:15:15:mouse", page.Value("pos"));
Assert.Equal("97:97:65:36:35:45", page.Value("[new KeyboardEvent('keypress',{key:'a'}).charCode,new KeyboardEvent('keypress',{key:'a'}).which,new KeyboardEvent('keydown',{key:'a'}).keyCode,new KeyboardEvent('keydown',{key:'Home'}).keyCode,new KeyboardEvent('keydown',{key:'End'}).keyCode,new KeyboardEvent('keydown',{key:'Insert'}).keyCode].join(':')"));
}
[Fact]
public void Utf8CodecSupportsActualUnicodeStreamingBomAndFatalValidation()
{
var page = new Page();
Assert.Equal("中文😀", page.Value("new TextDecoder().decode(new TextEncoder().encode('中文😀'))"));
Assert.Equal("1:3", page.Value("var into=new Uint8Array(4),encoded=new TextEncoder().encodeInto('中😀',into);encoded.read+':'+encoded.written"));
Assert.Equal("|中", page.Value("var decoder=new TextDecoder();decoder.decode(new Uint8Array([0xE4,0xB8]),{stream:true})+'|'+decoder.decode(new Uint8Array([0xAD]))"));
Assert.Equal("A", page.Value("new TextDecoder().decode(new Uint8Array([239,187,191,65]))"));
Assert.Equal("\uFEFFA", page.Value("new TextDecoder('utf-8',{ignoreBOM:true}).decode(new Uint8Array([239,187,191,65]))"));
Assert.False(page.Runtime.Evaluate("new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array([255]))").Success);
Assert.False(page.Runtime.Evaluate("new TextDecoder('unsupported-encoding')").Success);
}
[Fact]
public void ControlKeysMoveAndExtendSelectionAndEnterCanBeCanceled()
{
var page = new Page(); page.Run("""var area=document.createElement('textarea');area.id='Area';area.value='ab😀cd\nsecond';document.body.append(area);area.setSelectionRange(4,4);"""); page.Dom.FocusNode(page.Document.GetElementById("Area"));
Assert.True(page.Dom.ApplyControlKey("ArrowLeft")); Assert.Equal("2:2", page.Value("area.selectionStart+':'+area.selectionEnd"));
Assert.True(page.Dom.ApplyControlKey("ArrowRight", true)); Assert.Equal("2:4", page.Value("area.selectionStart+':'+area.selectionEnd"));
page.Dom.ApplyControlKey("End"); Assert.Equal("6", page.Value("area.selectionStart"));
Assert.True(page.Dom.ApplyControlKey("Enter")); Assert.Equal("ab😀cd\n\nsecond", page.Value("area.value"));
page.Run("area.addEventListener('beforeinput',e=>e.preventDefault())"); Assert.False(page.Dom.ApplyControlKey("Enter"));
page.Dom.ApplyControlKey("a", control: true); Assert.Equal("0:14", page.Value("area.selectionStart+':'+area.selectionEnd"));
}
[Fact]
public void Utf16DecodersUseUnderlyingViewBytesAndTrueStreamingEndianness()
{
var page = new Page();
Assert.Equal("中文😀", page.Value("new TextDecoder('UTF-16LE').decode(new Uint16Array([0x4E2D,0x6587,0xD83D,0xDE00]))"));
Assert.Equal("中", page.Value("new TextDecoder('UTF-16BE').decode(new Uint8Array([0x4E,0x2D]))"));
Assert.Equal("|中", page.Value("var decoder=new TextDecoder('utf-16le');decoder.decode(new Uint8Array([0x2D]),{stream:true})+'|'+decoder.decode(new Uint8Array([0x4E]))"));
Assert.Equal("A", page.Value("new TextDecoder('utf-16le').decode(new Uint8Array([0xFF,0xFE,65,0]))"));
Assert.Equal("中", page.Value("var array=new Uint8Array([0,0,0x2D,0x4E,0,0]);new TextDecoder('utf-16le').decode(new Uint16Array(array.buffer,2,1))"));
Assert.False(page.Runtime.Evaluate("new TextDecoder('utf-16le',{fatal:true}).decode(new Uint8Array([65]))").Success);
}
[Fact]
public void RangeTracksActualContainersOffsetsTextCollapseAndMeasuredRectangles()
{
var measured = new List<(INode Start, int A, INode End, int B)>();
var page = new Page(host: new() { MeasureRange = (start, a, end, b) => { measured.Add((start, a, end, b)); return [new(10 + a * 8, 20, (b - a) * 8, 16)]; } });
page.Run("var text=document.getElementById('Child').firstChild,r=document.createRange();r.setStart(text,1);r.setEnd(text,4);");
Assert.Equal("hil:1:4:false:24", page.Value("[r.toString(),r.startOffset,r.endOffset,r.collapsed,r.getClientRects()[0].width].join(':')"));
Assert.Equal((page.Document.GetElementById("Child")!.FirstChild!, 1, page.Document.GetElementById("Child")!.FirstChild!, 4), measured[^1]);
Assert.Equal("true:1:18", page.Value("r.collapse(true);[r.collapsed,r.endOffset,r.getBoundingClientRect().left].join(':')"));
Assert.Equal("Child", page.Value("r.selectNodeContents(document.getElementById('Child'));r.cloneRange().toString()"));
Assert.False(page.Runtime.Evaluate("r.setStart(text,999)").Success);
}
[Fact]
public void PointToCaretUsesActualHitNodeAndFontMeasurements()
{
var page = new Page(host: new() { Measure = _ => new(20, 30, 50, 16), MeasureText = (text, _) => text.Length * 10 });
Assert.Equal("2:true", page.Value("var caret=document.caretRangeFromPoint(43,35);caret.startOffset+':'+(caret.startContainer===document.getElementById('Child').firstChild)"));
Assert.Equal("2", page.Value("document.caretPositionFromPoint(43,35).offset"));
}
[Fact]
public void ShiftedKeysKeepPhysicalKeyCodesWhileKeypressUsesTheCharacter()
{
var page = new Page();
Assert.Equal("49:65:186:222:33", page.Value("[new KeyboardEvent('keydown',{key:'!',code:'Digit1',shiftKey:true}).keyCode,new KeyboardEvent('keydown',{key:'A',code:'KeyA',shiftKey:true}).keyCode,new KeyboardEvent('keydown',{key:':',code:'Semicolon',shiftKey:true}).keyCode,new KeyboardEvent('keydown',{key:'\"',code:'Quote',shiftKey:true}).keyCode,new KeyboardEvent('keypress',{key:'!',code:'Digit1',shiftKey:true}).which].join(':')"));
}
[Fact]
public void ThrowingEventListenersRestoreLexicalScopeAndContinueOtherListeners()
{
var page = new Page();
page.Run("""
var calls=[];const button=document.getElementById('Child');
function install(){const closed='scope-restored';button.addEventListener('click',()=>{const privateLocal='private';throw Error(privateLocal);});
button.addEventListener('click',()=>calls.push(closed));}
install();button.dispatchEvent(new Event('click'));button.dispatchEvent(new Event('click'));
""");
Assert.Equal("scope-restored,scope-restored", page.Value("calls.join(',')"));
Assert.Equal("undefined", page.Value("typeof privateLocal"));
Assert.Equal("42", page.Value("function nested(){try{(()=>{throw Error('nested')})()}catch(error){return (()=>21)()*2}}nested()"));
}
private sealed class Page
{
public Page(BrowserLimits? limits = null, BrowserDomHost? host = null)
{
limits ??= new(); Runtime = new(limits); Document = new HtmlParser().ParseDocument("<!doctype html><html><head></head><body><div id='Parent'><button id='Child'>Child</button></div></body></html>");
Dom = new(Runtime, Document, new Uri("https://example.test/Docs/Index"), limits, host);
}
public JavaScriptRuntime Runtime { get; }
public IDocument Document { get; }
public DomBridge Dom { get; }
public void Run(string source) { var result = Runtime.Evaluate(source); Assert.True(result.Success, result.Error); }
public string Value(string source) { var result = Runtime.Evaluate(source); Assert.True(result.Success, result.Error); return result.Value; }
}
}
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace XFESystem.Browser.Tests;
public sealed class BrowserDomTests
{
[Fact]
public void WrappersPreserveNodeIdentityAndActualTreeMutations()
{
var page = new Page();
page.Run("""
const p=document.getElementById('Parent'), a=document.getElementById('Child');
const n=document.createElement('span');n.id='Added';n.textContent='Hello';p.insertBefore(n,a);
const fragment=document.createDocumentFragment();fragment.append(document.createTextNode(' tail'),document.createElement('b'));p.appendChild(fragment);
if(p.children.item(0)!==n||document.querySelector('#Added')!==n||n.ownerDocument!==document)throw Error('identity');
if(!(n instanceof HTMLElement)||!(document instanceof Document)||fragment.childNodes.length!==0)throw Error('types');
a.remove();n.setAttribute('data-example','yes');
""");
Assert.Equal("Hello tail", page.Document.GetElementById("Parent")!.TextContent);
Assert.Null(page.Document.GetElementById("Child"));
Assert.Equal("yes", page.Document.GetElementById("Added")!.GetAttribute("data-example"));
}
[Fact]
public void EventCaptureTargetBubbleAndIdentityFollowActualAncestors()
{
var page = new Page();
page.Run("""
var trace=[];const p=document.getElementById('Parent'),c=document.getElementById('Child');
window.addEventListener('click',e=>trace.push('window:'+e.eventPhase),true);
document.addEventListener('click',e=>trace.push('document:'+e.eventPhase),true);
p.addEventListener('click',e=>trace.push('parent:'+e.eventPhase),true);
c.addEventListener('click',e=>trace.push('capture:'+e.eventPhase),true);
c.addEventListener('click',e=>{if(e.target!==c||e.currentTarget!==c)throw Error('target');trace.push('target:'+e.eventPhase);});
p.addEventListener('click',e=>trace.push('bubble:'+e.eventPhase));
var event=new MouseEvent('click',{bubbles:true,cancelable:true});c.dispatchEvent(event);
if(event.currentTarget!==null||event.eventPhase!==0)throw Error('cleanup');
""");
Assert.Equal("window:1,document:1,parent:1,capture:2,target:2,bubble:3", page.Value("trace.join(',')"));
}
[Fact]
public void ListenerRemovalOnceCancelableAndPassiveHaveRealEffects()
{
var page = new Page();
page.Run("""
var count=0;const c=document.getElementById('Child');function gone(){count+=100;}
c.addEventListener('test',gone);c.removeEventListener('test',gone);
c.addEventListener('test',()=>++count,{once:true});
c.addEventListener('test',e=>e.preventDefault(),{passive:true});
var first=new Event('test',{cancelable:true});if(!c.dispatchEvent(first)||first.defaultPrevented)throw Error('passive');
c.dispatchEvent(new Event('test'));c.addEventListener('test',e=>e.preventDefault());
var second=new Event('test',{cancelable:true});if(c.dispatchEvent(second)||!second.defaultPrevented)throw Error('cancel');
""");
Assert.Equal("1", page.Value("count"));
}
[Fact]
public void StopPropagationAndImmediatePropagationHaveDistinctSemantics()
{
var page = new Page();
page.Run("""
var calls=[];const p=document.getElementById('Parent'),c=document.getElementById('Child');
c.addEventListener('one',e=>{calls.push(1);e.stopPropagation();});c.addEventListener('one',()=>calls.push(2));p.addEventListener('one',()=>calls.push(3));
c.dispatchEvent(new Event('one',{bubbles:true}));
c.addEventListener('two',e=>{calls.push(4);e.stopImmediatePropagation();});c.addEventListener('two',()=>calls.push(5));
c.dispatchEvent(new Event('two',{bubbles:true}));
""");
Assert.Equal("1,2,4", page.Value("calls.join(',')"));
}
[Fact]
public void NativeDispatchUsesWebKeyboardCoordinatesAndReturnsCancellation()
{
var page = new Page();
page.Run("var seen='';document.getElementById('Child').addEventListener('keydown',e=>{seen=e.key+':'+e.ctrlKey+':'+e.keyCode;e.preventDefault();});");
Assert.False(page.Dom.DispatchEvent(page.Document.GetElementById("Child"), "keydown", new() { Key = "s", Code = "KeyS", CtrlKey = true }));
Assert.Equal("s:true:83", page.Value("seen"));
Assert.Same(page.Document.GetElementById("Child"), page.Dom.ResolveNode(page.Dom.NodeIdentity(page.Document.GetElementById("Child")!)));
}
[Fact]
public void ClassListAndStyleMutateActualAttributesAndNotifyLayout()
{
var changes = 0;
var page = new Page(host: new() { Mutated = () => ++changes });
page.Run("""
var c=document.getElementById('Child');c.classList.add('a','b');c.classList.toggle('a',false);c.classList.replace('b','c');
c.style.backgroundColor='#123456';c.style.setProperty('width','120px');c.style.removeProperty('width');
if(!c.classList.contains('c')||c.classList.length!==1||c.style.backgroundColor!=='#123456')throw Error('style');
""");
Assert.Equal("c", page.Document.GetElementById("Child")!.ClassName);
Assert.Equal("background-color:#123456", page.Document.GetElementById("Child")!.GetAttribute("style"));
Assert.True(changes >= 5);
}
[Fact]
public void LayoutGettersComeFromInjectedMeasuredLayout()
{
var page = new Page(host: new() { Measure = _ => new(12, 25, 180, 44), ComputedStyle = (_, property) => property == "color" ? "rgb(1, 2, 3)" : null, Viewport = () => new(1280, 720) });
Assert.Equal("12:69:180:1280:rgb(1, 2, 3)", page.Value("const c=document.getElementById('Child'),r=c.getBoundingClientRect();[r.x,r.bottom,c.clientWidth,innerWidth,getComputedStyle(c).color].join(':')"));
}
[Fact]
public void AddedScriptAndLinkNotifyResourceOwnerButInnerHtmlScriptStaysInert()
{
var resources = new List<IElement>();
var page = new Page(host: new() { ResourceRequested = resources.Add });
page.Run("var s=document.createElement('script');s.src='Code.js';document.head.appendChild(s);var l=document.createElement('link');l.href='Style.css';l.rel='stylesheet';document.head.append(l);document.body.innerHTML+='<script src=\"Inert.js\"></script>';");
Assert.Equal(new[] { "script", "link" }, resources.Select(element => element.LocalName));
Assert.Equal("https://example.test/Docs/Code.js", page.Value("s.src"));
}
[Fact]
public void MutationObserverRunsAsAMicrotaskAndDisconnects()
{
var page = new Page();
page.Run("""
var records=[];var observer=new MutationObserver(batch=>records.push(...batch.map(x=>x.type+':'+(x.attributeName||''))));
observer.observe(document.body,{subtree:true,attributes:true,childList:true});
document.getElementById('Child').setAttribute('title','New');document.body.append(document.createElement('aside'));
""");
Assert.Equal("attributes:title,childList:", page.Value("records.join(',')"));
page.Run("observer.disconnect();document.body.append(document.createElement('section'));");
Assert.Equal("2", page.Value("records.length"));
}
[Fact]
public void CompleteLoadingFiresActualLifecycleOnce()
{
var page = new Page();
page.Run("var states=[];document.addEventListener('readystatechange',()=>states.push(document.readyState));document.addEventListener('DOMContentLoaded',()=>states.push('dom'));window.addEventListener('load',()=>states.push('load'));");
page.Dom.CompleteLoading(); page.Dom.CompleteLoading();
Assert.Equal("interactive,dom,complete,load", page.Value("states.join(',')"));
}
[Fact]
public void FailedInnerHtmlPreservesOriginalWrappedNodeIdentity()
{
var page = new Page(new() { MaximumDomNodes = 12 });
page.Run("var original=document.getElementById('Child');");
Assert.False(page.Runtime.Evaluate("document.getElementById('Parent').innerHTML='<span>x</span>'.repeat(50)").Success);
Assert.Equal("true", page.Value("original===document.getElementById('Child')"));
}
[Fact]
public void ExplicitBridgeDoesNotExposeClrReflection()
{
var page = new Page();
Assert.Equal("undefined:undefined:undefined", page.Value("[typeof document.GetType,typeof document.body.GetType,typeof System].join(':')"));
}
[Fact]
public void DomInitializationAndHostCallbacksPreserveSmallStatementBudget()
{
var page = new Page(new() { MaximumStatements = 100 });
Assert.False(page.Runtime.Evaluate("while(true){document.getElementById('Child').classList.contains('x');}").Success);
Assert.Equal("42", page.Value("6*7"));
}
[Fact]
public void EditableTextareaDefaultInputUsesSelectionAndActualValue()
{
var page = new Page();
page.Run("var area=document.createElement('textarea');area.id='Area';area.value='abcdef';document.body.append(area);area.setSelectionRange(2,4);var inputs=[];area.addEventListener('input',e=>inputs.push(e.inputType+':'+e.data));");
page.Dom.FocusNode(page.Document.GetElementById("Area"));
Assert.True(page.Dom.ApplyTextInput("XY")); Assert.Equal("abXYef", page.Value("area.value"));
Assert.Equal("4:4", page.Value("area.selectionStart+':'+area.selectionEnd"));
Assert.True(page.Dom.ApplyTextInput("", -1)); Assert.Equal("abXef", page.Value("area.value"));
Assert.Equal("insertText:XY,deleteContentBackward:", page.Value("inputs.join(',')"));
page.Run("area.readOnly=true;"); Assert.False(page.Dom.ApplyTextInput("blocked"));
}
[Fact]
public void RangeReplacementPreservesSelectionAndCombinedControlBudget()
{
var page = new Page(new() { MaximumHtmlCharacters = 200 });
page.Run("var area=document.createElement('textarea');document.body.append(area);area.value='abcdef';area.setSelectionRange(4,6);area.setRangeText('XY',0,2,'preserve');");
Assert.Equal("XYcdef:4:6", page.Value("[area.value,area.selectionStart,area.selectionEnd].join(':')"));
Assert.False(page.Runtime.Evaluate("area.value='x'.repeat(198)").Success);
Assert.Equal("XYcdef", page.Value("area.value"));
}
[Fact]
public void CommentAndFailedCloneCannotBypassOrPoisonDomBudget()
{
var page = new Page(new() { MaximumHtmlCharacters = 160 });
Assert.False(page.Runtime.Evaluate("document.createComment('x'.repeat(160))").Success);
page.Run("document.getElementById('Child').textContent='Still works';");
Assert.Contains("Still works", page.Document.Body!.TextContent);
}
[Fact]
public void ResizeObserverReportsRealMeasuredChangesAndStopsWhenIdle()
{
var width = 120d; var page = new Page(host: new() { Measure = _ => new(10, 20, width, 40) });
page.Run("var sizes=[];var observer=new ResizeObserver(entries=>sizes.push(entries[0].contentRect.width));observer.observe(document.getElementById('Parent'));");
Assert.True(page.Runtime.Pump(1).Success); Assert.Equal("120", page.Value("sizes.join(',')")); Assert.Equal(0, page.Runtime.PendingTaskCount);
page.Dom.NotifyLayoutChanged(); page.Runtime.Pump(2); Assert.Equal("120", page.Value("sizes.join(',')"));
width = 240; page.Dom.NotifyLayoutChanged(); page.Runtime.Pump(3); Assert.Equal("120,240", page.Value("sizes.join(',')"));
page.Run("observer.disconnect()"); width = 300; page.Dom.NotifyLayoutChanged(); page.Runtime.Pump(4);
Assert.Equal("120,240", page.Value("sizes.join(',')")); Assert.Equal(0, page.Runtime.PendingTaskCount);
}
[Fact]
public void ChildCollectionsStayLiveAcrossRealMutations()
{
var page = new Page();
Assert.Equal("1:2:true", page.Value("const p=document.getElementById('Parent'),c=p.children,b=document.getElementsByTagName('button'),before=c.length;const n=document.createElement('button');p.append(n);[before,c.length,b.item(1)===n].join(':')"));
}
[Fact]
public void PointerCoordinatesAndKeyboardCharacterCodesMatchViewportAndEventType()
{
var page = new Page(host: new() { Viewport = () => new(800, 600, 100, 200), Measure = _ => new(10, 20, 50, 30) });
page.Run("var pos='';document.getElementById('Child').addEventListener('pointerdown',e=>pos=[e.clientX,e.pageX,e.pageY,e.offsetX,e.offsetY,e.pointerType].join(':'));");
page.Dom.DispatchEvent(page.Document.GetElementById("Child"), "pointerdown", new() { ClientX = 25, ClientY = 35 });
Assert.Equal("25:125:235:15:15:mouse", page.Value("pos"));
Assert.Equal("97:97:65:36:35:45", page.Value("[new KeyboardEvent('keypress',{key:'a'}).charCode,new KeyboardEvent('keypress',{key:'a'}).which,new KeyboardEvent('keydown',{key:'a'}).keyCode,new KeyboardEvent('keydown',{key:'Home'}).keyCode,new KeyboardEvent('keydown',{key:'End'}).keyCode,new KeyboardEvent('keydown',{key:'Insert'}).keyCode].join(':')"));
}
[Fact]
public void Utf8CodecSupportsActualUnicodeStreamingBomAndFatalValidation()
{
var page = new Page();
Assert.Equal("中文😀", page.Value("new TextDecoder().decode(new TextEncoder().encode('中文😀'))"));
Assert.Equal("1:3", page.Value("var into=new Uint8Array(4),encoded=new TextEncoder().encodeInto('中😀',into);encoded.read+':'+encoded.written"));
Assert.Equal("|中", page.Value("var decoder=new TextDecoder();decoder.decode(new Uint8Array([0xE4,0xB8]),{stream:true})+'|'+decoder.decode(new Uint8Array([0xAD]))"));
Assert.Equal("A", page.Value("new TextDecoder().decode(new Uint8Array([239,187,191,65]))"));
Assert.Equal("\uFEFFA", page.Value("new TextDecoder('utf-8',{ignoreBOM:true}).decode(new Uint8Array([239,187,191,65]))"));
Assert.False(page.Runtime.Evaluate("new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array([255]))").Success);
Assert.False(page.Runtime.Evaluate("new TextDecoder('unsupported-encoding')").Success);
}
[Fact]
public void ControlKeysMoveAndExtendSelectionAndEnterCanBeCanceled()
{
var page = new Page(); page.Run("""var area=document.createElement('textarea');area.id='Area';area.value='ab😀cd\nsecond';document.body.append(area);area.setSelectionRange(4,4);"""); page.Dom.FocusNode(page.Document.GetElementById("Area"));
Assert.True(page.Dom.ApplyControlKey("ArrowLeft")); Assert.Equal("2:2", page.Value("area.selectionStart+':'+area.selectionEnd"));
Assert.True(page.Dom.ApplyControlKey("ArrowRight", true)); Assert.Equal("2:4", page.Value("area.selectionStart+':'+area.selectionEnd"));
page.Dom.ApplyControlKey("End"); Assert.Equal("6", page.Value("area.selectionStart"));
Assert.True(page.Dom.ApplyControlKey("Enter")); Assert.Equal("ab😀cd\n\nsecond", page.Value("area.value"));
page.Run("area.addEventListener('beforeinput',e=>e.preventDefault())"); Assert.False(page.Dom.ApplyControlKey("Enter"));
page.Dom.ApplyControlKey("a", control: true); Assert.Equal("0:14", page.Value("area.selectionStart+':'+area.selectionEnd"));
}
[Fact]
public void Utf16DecodersUseUnderlyingViewBytesAndTrueStreamingEndianness()
{
var page = new Page();
Assert.Equal("中文😀", page.Value("new TextDecoder('UTF-16LE').decode(new Uint16Array([0x4E2D,0x6587,0xD83D,0xDE00]))"));
Assert.Equal("中", page.Value("new TextDecoder('UTF-16BE').decode(new Uint8Array([0x4E,0x2D]))"));
Assert.Equal("|中", page.Value("var decoder=new TextDecoder('utf-16le');decoder.decode(new Uint8Array([0x2D]),{stream:true})+'|'+decoder.decode(new Uint8Array([0x4E]))"));
Assert.Equal("A", page.Value("new TextDecoder('utf-16le').decode(new Uint8Array([0xFF,0xFE,65,0]))"));
Assert.Equal("中", page.Value("var array=new Uint8Array([0,0,0x2D,0x4E,0,0]);new TextDecoder('utf-16le').decode(new Uint16Array(array.buffer,2,1))"));
Assert.False(page.Runtime.Evaluate("new TextDecoder('utf-16le',{fatal:true}).decode(new Uint8Array([65]))").Success);
}
[Fact]
public void RangeTracksActualContainersOffsetsTextCollapseAndMeasuredRectangles()
{
var measured = new List<(INode Start, int A, INode End, int B)>();
var page = new Page(host: new() { MeasureRange = (start, a, end, b) => { measured.Add((start, a, end, b)); return [new(10 + a * 8, 20, (b - a) * 8, 16)]; } });
page.Run("var text=document.getElementById('Child').firstChild,r=document.createRange();r.setStart(text,1);r.setEnd(text,4);");
Assert.Equal("hil:1:4:false:24", page.Value("[r.toString(),r.startOffset,r.endOffset,r.collapsed,r.getClientRects()[0].width].join(':')"));
Assert.Equal((page.Document.GetElementById("Child")!.FirstChild!, 1, page.Document.GetElementById("Child")!.FirstChild!, 4), measured[^1]);
Assert.Equal("true:1:18", page.Value("r.collapse(true);[r.collapsed,r.endOffset,r.getBoundingClientRect().left].join(':')"));
Assert.Equal("Child", page.Value("r.selectNodeContents(document.getElementById('Child'));r.cloneRange().toString()"));
Assert.False(page.Runtime.Evaluate("r.setStart(text,999)").Success);
}
[Fact]
public void PointToCaretUsesActualHitNodeAndFontMeasurements()
{
var page = new Page(host: new() { Measure = _ => new(20, 30, 50, 16), MeasureText = (text, _) => text.Length * 10 });
Assert.Equal("2:true", page.Value("var caret=document.caretRangeFromPoint(43,35);caret.startOffset+':'+(caret.startContainer===document.getElementById('Child').firstChild)"));
Assert.Equal("2", page.Value("document.caretPositionFromPoint(43,35).offset"));
}
[Fact]
public void ShiftedKeysKeepPhysicalKeyCodesWhileKeypressUsesTheCharacter()
{
var page = new Page();
Assert.Equal("49:65:186:222:33", page.Value("[new KeyboardEvent('keydown',{key:'!',code:'Digit1',shiftKey:true}).keyCode,new KeyboardEvent('keydown',{key:'A',code:'KeyA',shiftKey:true}).keyCode,new KeyboardEvent('keydown',{key:':',code:'Semicolon',shiftKey:true}).keyCode,new KeyboardEvent('keydown',{key:'\"',code:'Quote',shiftKey:true}).keyCode,new KeyboardEvent('keypress',{key:'!',code:'Digit1',shiftKey:true}).which].join(':')"));
}
[Fact]
public void ThrowingEventListenersRestoreLexicalScopeAndContinueOtherListeners()
{
var page = new Page();
page.Run("""
var calls=[];const button=document.getElementById('Child');
function install(){const closed='scope-restored';button.addEventListener('click',()=>{const privateLocal='private';throw Error(privateLocal);});
button.addEventListener('click',()=>calls.push(closed));}
install();button.dispatchEvent(new Event('click'));button.dispatchEvent(new Event('click'));
""");
Assert.Equal("scope-restored,scope-restored", page.Value("calls.join(',')"));
Assert.Equal("undefined", page.Value("typeof privateLocal"));
Assert.Equal("42", page.Value("function nested(){try{(()=>{throw Error('nested')})()}catch(error){return (()=>21)()*2}}nested()"));
}
private sealed class Page
{
public Page(BrowserLimits? limits = null, BrowserDomHost? host = null)
{
limits ??= new(); Runtime = new(limits); Document = new HtmlParser().ParseDocument("<!doctype html><html><head></head><body><div id='Parent'><button id='Child'>Child</button></div></body></html>");
Dom = new(Runtime, Document, new Uri("https://example.test/Docs/Index"), limits, host);
}
public JavaScriptRuntime Runtime { get; }
public IDocument Document { get; }
public DomBridge Dom { get; }
public void Run(string source) { var result = Runtime.Evaluate(source); Assert.True(result.Success, result.Error); }
public string Value(string source) { var result = Runtime.Evaluate(source); Assert.True(result.Success, result.Error); return result.Value; }
}
}