Files
rustpad/tests/frontend-optimizations-browser.py
T
2026-10-14 22:03:06 +02:00

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()