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(); 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+='';"); 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='x'.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("
"); 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; } } }