XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESystem

【Rust】XFE系统,基于Rust和汇编

公开
关注 0 Fork 0 Star 0
UTF-8
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; }
    }
}