158 lines
10 KiB
Python
158 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Isolated browser tests of DOM reconciliation, preview alignment and owner labels.
|
|
Run from the repository root. Does not emulate the server or OT protocol.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from pathlib import Path
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def module_url(content: str) -> str:
|
|
return "data:text/javascript;base64," + base64.b64encode(content.encode()).decode()
|
|
|
|
|
|
def main():
|
|
reconcile = (ROOT / "static/js/dom-reconcile.js").read_text()
|
|
editor_source = (ROOT / "static/js/note-editor.js").read_text()
|
|
presence_source = editor_source.split(" function updatePresence(users) {", 1)[1].split(" function updateLatency", 1)[0]
|
|
presence_source = "function updatePresence(users) {" + presence_source
|
|
badges_source = editor_source.split(" let renderedAuthorshipOwners = [];", 1)[1].split(" function syncOwnerLabels()", 1)[0]
|
|
badges_source = "let renderedAuthorshipOwners = [];" + badges_source
|
|
geometry_source = "let previewPositionCache = null;" + editor_source.split(" let previewPositionCache = null;", 1)[1].split(" function previewScrollAnchor()", 1)[0]
|
|
markdown = (ROOT / "static/js/markdown.js").read_text()
|
|
markdown = "const EMOJI_SHORTCODES = {}; const t = (key, v, fallback) => fallback; const parseImageAlias = () => null;\n" + "\n".join(
|
|
line for line in markdown.splitlines() if not line.startswith("import ")
|
|
) + "\nexport { firstRenderedTextRect };\n"
|
|
html = '''<!doctype html><html><head><meta charset="utf-8"><style>
|
|
.owner-labels { position: relative; height: 100px; width: 250px; overflow:hidden; }
|
|
.owner-labels-canvas { position:absolute;inset:0; }
|
|
.owner-label-group { position:absolute;right:12px;transform:translateY(-50%); }
|
|
#preview, #original { width:600px; height: 350px; overflow:auto; padding:24px; border:1px solid #ccc; }
|
|
.preview-source-line {display:block; position:relative; min-height: 24px; }
|
|
.preview-source-line::before {content:attr(data-source-line); position:absolute;left:-50px;line-height:16px;font-size:13px;}
|
|
</style></head><body>
|
|
<ul id="users"></ul><span id="room-count"></span><ul id="room-users"></ul><div id="participant-badges"></div>
|
|
<div id="owner-labels" class="owner-labels"><div class="owner-labels-canvas"><span class="owner-label-group" style="top:250px">Alice</span></div></div>
|
|
<article id="preview"></article><article id="original"></article>
|
|
<script type="module">
|
|
import { reconcileChildren } from "__RECONCILE__";
|
|
import { alignPreviewLineNumbers, firstRenderedTextRect } from "__MARKDOWN__";
|
|
const roomCount=document.querySelector('#room-count');
|
|
const roomUsers=document.querySelector('#room-users');
|
|
const participantBadges=document.querySelector('#participant-badges');
|
|
let presenceUsers=[];
|
|
const authorshipColorsEnabled=true, authorshipMode='simple';
|
|
const t=(key, vars, fallback)=>fallback;
|
|
const defaultColorFor=()=> '#444444';
|
|
const ownerName=value=>String(value).split('\u001f')[0];
|
|
const colorFor=()=> '#aabbcc';
|
|
function renderGutter() { throw new Error('Presence must not cause a gutter rebuild'); }
|
|
__BADGES_SOURCE__
|
|
__PRESENCE_SOURCE__
|
|
function check(result, label) {if (!result) throw new Error(label);}
|
|
const list = document.querySelector('#users');
|
|
const a = document.createElement('li'), b=document.createElement('li'), c=document.createElement('li'), d=document.createElement('li');
|
|
a.textContent='A';b.textContent='B';c.textContent='C';d.textContent='D';
|
|
list.append(a,b,c);
|
|
reconcileChildren(list,[c,a,d]);
|
|
check(list.children.length===3 && list.children[0]===c && list.children[1]===a && list.children[2]===d, 'reordering/removal/insertion');
|
|
const observer = new MutationObserver(()=>{});
|
|
observer.observe(list,{childList:true});
|
|
reconcileChildren(list,[c,a,d]);
|
|
check(observer.takeRecords().length === 0, 'unchanged list should not mutate the DOM');
|
|
observer.disconnect();
|
|
const participants=Array.from({length:1000},(_,i)=>({name:`User ${i}`,compact_name:`U${i}`,color:'#abcdef'}));
|
|
updatePresence(participants);
|
|
check(roomUsers.children.length===1000 && participantBadges.children.length===1000, '1000 participants rendered');
|
|
const rowRefs=Array.from(roomUsers.children);
|
|
const badgeRefs=Array.from(participantBadges.children);
|
|
updatePresence([...participants,{name:'New person',color:'#123456'}]);
|
|
check(roomUsers.children.length===1001 && roomUsers.children[0]===rowRefs[0] && roomUsers.children[999]===rowRefs[999], 'presence DOM reused');
|
|
check(participantBadges.children[0]===badgeRefs[0] && participantBadges.children[999]===badgeRefs[999], 'badge DOM reused');
|
|
const presenceMutations=new MutationObserver(()=>{});
|
|
presenceMutations.observe(roomUsers,{childList:true});
|
|
updatePresence([...participants.slice(1),{name:'New person',color:'#123456'}]);
|
|
const changes=presenceMutations.takeRecords();
|
|
check(changes.length===1 && changes[0].removedNodes.length===1, 'single early removal does not move remaining users');
|
|
presenceMutations.disconnect();
|
|
updatePresence([{name:'Duplicate',color:'#abcdef'},{name:'Duplicate',color:'#123456'}]);
|
|
check(roomUsers.children.length===2 && participantBadges.children.length===1, 'same-name participants count preserved, badge deduplicated');
|
|
updatePresence([{name:'<script>alert(1)<'+'/script>',color:'red'}]);
|
|
check(roomUsers.querySelector('script')===null && participantBadges.querySelector('script')===null, 'text remains escaped');
|
|
const ownerLabels=document.querySelector('#owner-labels');
|
|
const canvas=ownerLabels.firstElementChild;
|
|
const group=canvas.firstElementChild;
|
|
canvas.style.transform='translate3d(0,-170px,0)';
|
|
let top=group.getBoundingClientRect().top-ownerLabels.getBoundingClientRect().top;
|
|
check(Math.abs(top - (250 - 170 - group.getBoundingClientRect().height/2)) < 1, 'owner labels scroll as one canvas');
|
|
function oldAlign(root) {
|
|
const styles=getComputedStyle(root), targetLeft=parseFloat(styles.paddingLeft||'0')-50;
|
|
const rootLeft=root.getBoundingClientRect().left;
|
|
root.querySelectorAll('.preview-source-line').forEach(line=>{
|
|
const lineRect=line.getBoundingClientRect();
|
|
const lineLeft=lineRect.left-rootLeft+root.scrollLeft;
|
|
line.style.setProperty('--preview-line-left', `${targetLeft - lineLeft}px`);
|
|
const textRect=firstRenderedTextRect(line);
|
|
const lineStyles=getComputedStyle(line), gutterStyles=getComputedStyle(line,'::before');
|
|
const gutterLineHeight=parseFloat(gutterStyles.lineHeight)||parseFloat(gutterStyles.fontSize)||0;
|
|
const textLineHeight=textRect?.height||0;
|
|
const borderTop=parseFloat(lineStyles.borderTopWidth)||0;
|
|
const textTop=textRect?textRect.top-lineRect.top-borderTop:0;
|
|
line.style.setProperty('--preview-line-top', `${Math.max(0,textTop+(textLineHeight-gutterLineHeight)/2)}px`);
|
|
});
|
|
}
|
|
const preview=document.querySelector('#preview'), original=document.querySelector('#original');
|
|
const rows=Array.from({length:150},(_,i)=>`<p class="preview-source-line" data-source-line="${i+1}">Row ${i} some text</p>`).join('');
|
|
preview.innerHTML=rows;original.innerHTML=rows;
|
|
oldAlign(original);
|
|
alignPreviewLineNumbers(preview);
|
|
const oldRows=original.querySelectorAll('.preview-source-line');
|
|
const newRows=preview.querySelectorAll('.preview-source-line');
|
|
for(let i=0;i<newRows.length;i++) {
|
|
for(const prop of ['--preview-line-left','--preview-line-top']) {
|
|
const before=parseFloat(oldRows[i].style.getPropertyValue(prop));
|
|
const after=parseFloat(newRows[i].style.getPropertyValue(prop));
|
|
check(Math.abs(before-after)<0.05, 'alignment parity at '+i+' '+prop+': '+before+' != '+after);
|
|
}
|
|
}
|
|
// Cached source geometry must not be remeasured during scroll, but must
|
|
// rebuild when the preview DOM/layout changes.
|
|
__GEOMETRY_SOURCE__
|
|
const firstPositions=previewLinePositions();
|
|
check(previewLinePositions()===firstPositions, 'preview geometry cached');
|
|
preview.scrollTop=100;
|
|
check(previewLinePositions()===firstPositions, 'scroll does not discard geometry');
|
|
preview.insertAdjacentHTML('beforeend','<p class="preview-source-line" data-source-line="151">Last line</p>');
|
|
await new Promise(resolve=>setTimeout(resolve,50));
|
|
const changedPositions=previewLinePositions();
|
|
check(changedPositions!==firstPositions && changedPositions.length===firstPositions.length+1, 'DOM mutation invalidates geometry');
|
|
window.frontendTestsPassed=true;
|
|
</script></body></html>'''.replace("__RECONCILE__", module_url(reconcile)).replace("__MARKDOWN__", module_url(markdown)).replace("__BADGES_SOURCE__", badges_source).replace("__PRESENCE_SOURCE__", presence_source).replace("__GEOMETRY_SOURCE__", geometry_source)
|
|
with sync_playwright() as playwright:
|
|
browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox", "--disable-dev-shm-usage"])
|
|
try:
|
|
page = browser.new_page(viewport={"width": 1100, "height": 780})
|
|
errors = []
|
|
page.on("pageerror", lambda error: errors.append(str(error)))
|
|
page.set_content(html)
|
|
try:
|
|
page.wait_for_function("window.frontendTestsPassed===true", timeout=15000)
|
|
except Exception:
|
|
raise AssertionError(f"Browser errors: {errors}")
|
|
assert not errors, errors
|
|
print("PASS: keyed DOM list updates preserve node identity; identical list has zero mutations")
|
|
print("PASS: real presence update handles 1000 users with row/badge reuse, duplicates and XSS-safe text")
|
|
print("PASS: owner-label canvas transforms; preview alignment parity for 150 lines in Chromium")
|
|
print("PASS: preview geometry cache reuses measurements on scroll and invalidates on content change")
|
|
finally:
|
|
browser.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|