This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 22:03:06 +02:00
1 parent 0abc8e871d
commit 4da41a1404
10 files changed
+458 -49

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
#!/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()
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
const source = readFileSync('static/js/authorship.js', 'utf8');
const { lineAuthors, parseAuthorship } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
// Reference implementation from 0.2.89; include it to detect attribution,
// color and tie-breaking regressions independently of the optimized code.
function referenceLineAuthors(content, model) {
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
return starts.map((start, index) => {
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
const totals = new Map();
const representatives = new Map();
for (const span of model?.spans || []) {
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (!overlap) continue;
const identity = String(span.owner || '').split('\u001f', 1)[0];
totals.set(identity, (totals.get(identity) || 0) + overlap);
representatives.set(identity, span.owner);
}
return [...totals.entries()]
.sort((a, b) => b[1] - a[1])
.map(([identity]) => representatives.get(identity));
});
}
let seed = 42;
function random(n) { seed = (seed * 1664525 + 1013904223) >>> 0; return seed % n; }
const owners = ['A\u001f#ff0000', 'B', 'A\u001f#00ff00', 'C', 'D', ''];
for (let sample = 0; sample < 400; sample++) {
const content = Array.from({ length: 1 + random(65) }, (_, i) => {
const base = ['text', '', '🙂 éół', ' x', '......'][random(5)];
return base + (i ? '\n' : '');
}).join('');
const spans = [];
for (let i = 0; i < 4 + random(100); i++) {
const start = random(content.length + 1);
spans.push({ start, end: start + random(content.length + 4), owner: owners[random(owners.length)] });
}
const canonical = parseAuthorship(content, { version: 2, spans });
assert.deepEqual(lineAuthors(content, canonical), referenceLineAuthors(content, canonical), `canonical parity sample ${sample}`);
assert.deepEqual(lineAuthors(content, { spans }), referenceLineAuthors(content, { spans }), `noncanonical parity sample ${sample}`);
}
for (const content of ['', 'a', '\n', '🙂\n\nB\n', 'line1\nline2\n']) {
for (const model of [{ spans: [] }, { spans: [{ start: 0, end: 999, owner: 'one' }] }]) {
assert.deepEqual(lineAuthors(content, model), referenceLineAuthors(content, model));
}
}
const n = 6000;
const lines = [], spans = [];
let offset = 0;
for (let i = 0; i < n; i++) {
const line = `row ${String(i).padStart(5, '0')} lorem ipsum\n`;
lines.push(line);
spans.push({ start: offset, end: offset + line.length, owner: `user${i % 37}` });
offset += line.length;
}
const text = lines.join('');
const model = { version: 2, spans };
assert.deepEqual(lineAuthors(text, model), referenceLineAuthors(text, model));
function medianDuration(fn) {
const ms = [];
for (let i = 0; i < 7; i++) { const start = performance.now(); fn(); ms.push(performance.now() - start); }
ms.sort((a, b) => a - b);
return ms[3];
}
medianDuration(() => lineAuthors(text, model));
const oldMs = medianDuration(() => referenceLineAuthors(text, model));
const newMs = medianDuration(() => lineAuthors(text, model));
console.log(`PASS: 400 randomized canonical + noncanonical parity cases; 6000 lines, 6000 spans: reference ${oldMs.toFixed(2)} ms; optimized ${newMs.toFixed(2)} ms; ${(oldMs / newMs).toFixed(1)}x`);