// Row 154 (#1648, Q168): the visual editor in a browser, under the Console's own Content-Security-Policy. // Same harness as docs-browser.test.mjs: a script added before the page loads answers /api/config with edit // on and records each POST /api/project-file, and the reads go to the real routes with stubbed bus answers. // Row 148's checks (the Source view, the save receipts) are docs-browser.test.mjs's and run unchanged there. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { startServer } from '../src/serve.mjs'; import { browser } from './browser.mjs'; import { fixture, close } from './fixture.mjs'; import { EVERY } from './helpers/every-element.mjs'; const C1 = '1'.repeat(40), C2 = '2'.repeat(40); const HOSTILE = [ '# Title', '', '', '', '', '[click](javascript:window.injected=3) [data](data:text/html,) [ok](https://example.com/a)', '', '[evil](//evil.example/x)', '', '[sp](https://a b.example/)', '', '![pixel](https://tracker.example/p.png)', '', '![svg](data:image/svg+xml,)', '', '', '', '``', '', '```', '', '```', '', ].join('\n'); const MIX = '# Title\n\nFirst para.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n'; const files = { 'docs/hostile.md': HOSTILE, 'docs/every.md': EVERY, 'docs/mix.md': MIX, 'docs/empty.md': '', 'docs/tilde.md': '~~~a`b\ncode\n~~~\n' }; // Chromium 156 checks script-src-attr when an inline handler attribute is created, not when the handler would run, and does // so in a document with no window as well (WPT PR 62762; PR 64290 later skips a windowless document, and 156 lacks it). So // prosemirror-view's parse of pasted HTML into a fresh createHTMLDocument raises one report per handler against the page's // policy, though no handler there can run. The page record sets aside only reports whose event names, as the place the // attribute was created, the one statement below in editor.js, and whose target is the document. That filter alone cannot // tell a handler that stayed in the detached document from one that was attached to the live page and taken off again // before the report was sent: Chromium then also targets the document. So the page also records, with a MutationObserver // on the live document that does not look at CSP events, every element that enters the page carrying an on* attribute and // every on* attribute set on an element in the page (STUB, window.__live; quiet() asserts it stays empty). Mutation records // outlast an attach and a removal in one turn. A handler put on a live element by other code also reports another file or // line, and a release that checks only when a handler runs reports where it runs, never at this parse. INERT_PARSE pins the // lines that make `elt` the body of a document from detachedDoc(), and DETACHED pins that detachedDoc() makes a new // createHTMLDocument each call. const BUNDLE = readFileSync(new URL('../src/public/editor.js', import.meta.url), 'utf8').split('\n'); const INERT_PARSE = [ ' function readHTML(html) {', ' let metas = /^(\\s*]*>)*/.exec(html);', ' if (metas) html = html.slice(metas[0].length);', ' let doc3 = detachedDoc(), elt = doc3.body;', ' let firstTag = /<([a-z][^>\\s]+)/i.exec(html), wrap2;', ' if (wrap2 = firstTag && wrapMap[firstTag[1].toLowerCase()])', ' html = wrap2.map((n) => "<" + n + ">").join("") + html + wrap2.map((n) => "").reverse().join("");', ' elt.innerHTML = maybeWrapTrusted(html);', ]; const DETACHED = [' function detachedDoc() {', ' return document.implementation.createHTMLDocument("title");', ' }']; const found = lines => BUNDLE.flatMap((l, i) => lines.every((s, j) => BUNDLE[i + j] === s) ? [i] : []); assert.ok(found(INERT_PARSE).length === 1 && found(DETACHED).length === 1 && BUNDLE.filter(l => /\bdetachedDoc\s*=|function detachedDoc\b/.test(l)).length === 1, 'editor.js parses pasted HTML in readHTML into the body of a new createHTMLDocument; the CSP record below is keyed on that line'); const INERT_PARSE_LINE = found(INERT_PARSE)[0] + INERT_PARSE.length; const STUB = `(() => { const real = window.fetch.bind(window); window.__saves = []; window.__answers = []; window.__rejections = []; window.__csp = []; window.__cspInert = []; window.__errors = []; addEventListener('unhandledrejection', e => window.__rejections.push(String(e.reason?.message ?? e.reason))); addEventListener('securitypolicyviolation', e => (e.violatedDirective === 'script-src-attr' && e.sourceFile === location.origin + '/editor.js' && e.lineNumber === ${INERT_PARSE_LINE} && e.target === document ? window.__cspInert : window.__csp).push(e.violatedDirective + ' ' + e.blockedURI)); addEventListener('error', e => window.__errors.push(String(e.message))); window.__live = []; const handlers = el => [...el.attributes].map(a => a.name).filter(n => /^on/i.test(n)); new MutationObserver(records => { for (const r of records) { if (r.type === 'attributes' && /^on/i.test(r.attributeName)) window.__live.push('set ' + r.attributeName + ' on ' + r.target.localName); for (const n of r.addedNodes) if (n.nodeType === 1) for (const e of [n, ...n.querySelectorAll('*')]) if (handlers(e).length) window.__live.push('added ' + e.localName + ' with ' + handlers(e)); } }).observe(document, { subtree: true, childList: true, attributes: true }); window.fetch = async (url, init = {}) => { const u = String(url); if (u === '/api/config') { const c = await (await real(url, init)).json(); return new Response(JSON.stringify({ ...c, send: !location.search.includes('noedit'), edit: !location.search.includes('noedit') }), { headers: { 'content-type': 'application/json' } }); } if (u !== '/api/project-file' || init.method !== 'POST') return real(url, init); window.__saves.push({ body: init.body }); const a = window.__answers.shift(); if (!a) throw new TypeError('no answer queued'); return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } }); }; })();`; // One page over the fixture server, with the helpers every test here uses. async function session(run) { const reads = []; const f = await fixture(), b = await browser(); const web = await startServer({ port: 0, board: f.boardURL, reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) }, projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }), projectFile: async args => { reads.push(args.path); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; }, }); const base = `http://127.0.0.1:${web.address().port}/`; const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); const h = { b, base, wait, reads, q: expr => b.evaluate(expr), open: async (path, query = '') => { await b.navigate(`${base}${query}#/docs/stack/${encodeURIComponent(path)}`); await wait('document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")'); }, pm: () => wait('document.querySelector("#doc-visual .ProseMirror")'), answer: a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`), // What the page holds that must stay empty, whatever else a test does. quiet: async () => { assert.deepEqual(await b.evaluate('({ csp: window.__csp, errors: window.__errors, rejections: window.__rejections, injected: window.injected ?? null })'), { csp: [], errors: [], rejections: [], injected: null }); // read apart from the CSP record: the filter on it cannot tell a handler that entered the page and left again assert.deepEqual(await b.evaluate('window.__live'), [], 'no element with an inline handler entered the live page'); }, // The editor's inert-ness: nothing in it can run or load. inert: () => b.evaluate(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); return { // prosemirror-view's own src-less (a cursor hack after a non-editable node) is not content bad: [...p.querySelectorAll('script, img, iframe, object, embed, style, link, form, svg, video, audio, source, base, meta')].filter(e => !e.matches('img.ProseMirror-separator:not([src])')).map(e => e.localName), handlers: [...p.querySelectorAll('*')].filter(e => [...e.attributes].some(a => /^on/i.test(a.name))).length, links: [...p.querySelectorAll('a')].map(a => [a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target')]), hrefs: [...p.querySelectorAll('[href], [src], [srcset], [data], [action], [formaction]')].filter(e => e.localName !== 'a').length, fetched: performance.getEntriesByType('resource').map(r => r.name).filter(n => /tracker\\.example|evil\\.example/.test(n)), injected: window.injected ?? null }; })()`), save: async () => { await b.evaluate('document.querySelector("#doc-save").click(), true'); await wait('document.querySelector("#doc-status").textContent.startsWith("Saved")'); }, saved: async () => (await b.evaluate('window.__saves')).map(s => JSON.parse(s.body)), done: async () => { await b.close(); await close(web); await f.close(); }, }; try { await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.viewport(1280); await run(h); } finally { await h.done(); } } test('the Console\'s CSP stands and the editor runs under it; hostile markdown on the load path runs and loads nothing', { timeout: 120000 }, async () => { await session(async h => { const csp = (await fetch(h.base)).headers.get('content-security-policy'); assert.match(csp, /(^|; )script-src 'self'(;|$)/); assert.doesNotMatch(csp, /unsafe-eval|script-src[^;]*unsafe-inline|data:|blob:/); await h.open('docs/hostile.md'); await h.pm(); assert.equal(await h.q('document.querySelector("#doc-mode-visual").getAttribute("aria-pressed")'), 'true', 'a page opens in the visual editor'); assert.equal(await h.q('!!document.querySelector("#doc-text")'), false); assert.equal(await h.q('typeof window.MosaicEditor.mount'), 'function', 'editor.js ran under script-src self'); assert.equal(await h.q('getComputedStyle(document.querySelector(".ed-toolbar")).display'), 'flex', 'editor.css applied'); const view = await h.inert(); assert.deepEqual(view.bad, []); assert.equal(view.handlers, 0); assert.equal(view.hrefs, 0); assert.deepEqual(view.fetched, []); assert.equal(view.injected, null); // markdown-it itself makes no link of a javascript: or a data: target, so those stay text; the paragraph holding them // is not a refused block, and its one good link is a real link. A target markdown-it accepts but the gate refuses // (`//evil.example/x`) makes its whole block a source block; a target with a space is no link to markdown-it at all. assert.deepEqual(view.links, [['https://example.com/a', 'noopener noreferrer', '_blank']], 'the one good link is the only anchor on the page'); const shown = await h.q('document.querySelector("#doc-visual .ProseMirror").textContent'); assert.ok(shown.includes('') && shown.includes('javascript:window.injected=3') && shown.includes('data:text/html') && shown.includes('https://tracker.example/p.png'), 'what the file says shows as text'); assert.equal(await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw")].some(e => e.textContent.includes("[evil](//evil.example/x)"))'), true, 'a target the gate refuses leaves its block as source text'); assert.ok(shown.includes('[sp](https://a b.example/)'), 'a target with a space stays text'); // typing works under the policy: an edit changes the draft and turns Save on await h.q('(() => { const p = document.querySelector("#doc-visual .ProseMirror"); p.focus(); getSelection().selectAllChildren(p); getSelection().collapseToEnd(); return true; })()'); await h.b.call('Input.insertText', { text: 'typed' }); await h.wait('!document.querySelector("#doc-save").disabled'); await h.quiet(); // a doc with every element: nothing in it runs, and a file's own links the gate accepts are real links await h.open('docs/every.md'); await h.pm(); const every = await h.inert(); assert.deepEqual([every.bad, every.handlers, every.hrefs, every.fetched, every.injected], [[], 0, 0, [], null]); for (const [href, rel, target] of every.links) { assert.match(href, /^(https:\/\/|http:\/\/|[^:]*$)/, `a link target the gate accepts: ${href}`); assert.equal(rel, 'noopener noreferrer'); assert.equal(target, '_blank'); } assert.ok(every.links.some(([href]) => href === 'https://example.com/a'), 'a plain https link is a link'); assert.equal(await h.q('!document.querySelector("#doc-visual .ProseMirror a[href^=javascript i], #doc-visual .ProseMirror a[href^=data i]")'), true); // open and save with no edit: nothing to save, and the editor holds the file's own bytes assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true); assert.equal(await h.q(`window.MosaicEditor.roundTrip(${JSON.stringify(files['docs/every.md'])}) === ${JSON.stringify(files['docs/every.md'])}`), true); await h.quiet(); // an empty file opens, and the same page read-only has no toolbar, no editing and no save await h.open('docs/empty.md'); await h.pm(); await h.open('docs/mix.md', '?noedit'); await h.pm(); assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror").getAttribute("contenteditable")'), 'false'); assert.equal(await h.q('!!document.querySelector(".ed-toolbar")'), false); assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true); await h.quiet(); }); }); test('hostile content pasted into the editor runs and loads nothing, and a bad link target is refused in the link bar', { timeout: 120000 }, async () => { await session(async h => { await h.open('docs/mix.md'); await h.pm(); const paste = (html, plain) => h.q(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); p.focus(); getSelection().selectAllChildren(p); getSelection().collapseToEnd(); const dt = new DataTransfer(); ${html == null ? '' : `dt.setData('text/html', ${JSON.stringify(html)});`} ${plain == null ? '' : `dt.setData('text/plain', ${JSON.stringify(plain)});`} p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return true; })()`); const HTML = [ '

pasted

', '', '', '

js', 'data', 'space', 'proto-relative', 'ok', 'rel

', '

img', 'img', 'img

', '', '', '', '
', '
styled
', ].join(''); await paste(HTML, '[x](javascript:window.injected=22) '); await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")'); const view = await h.inert(); assert.deepEqual(view.bad, [], 'no script, image, frame, object, style or form came in'); assert.equal(view.handlers, 0); assert.equal(view.hrefs, 0); assert.deepEqual(view.fetched, []); assert.equal(view.injected, null); assert.deepEqual(view.links.map(l => l[0]).sort(), ['../docs/rel.md', 'https://ok.example/p'], 'only targets the gate accepts became links'); for (const [, rel, target] of view.links) assert.deepEqual([rel, target], ['noopener noreferrer', '_blank']); assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror [style*=url]")'), null, 'no style attribute came in'); // pasting plain text that looks like markup or a link leaves it as text await paste(null, '[x](javascript:window.injected=24) ![i](https://tracker.example/z.png)'); await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("window.injected=25")'); const plain = await h.inert(); assert.deepEqual([plain.bad, plain.handlers, plain.fetched, plain.injected], [[], 0, [], null]); assert.ok(!plain.links.some(l => /javascript/i.test(l[0]))); // what the save writes holds no live script, frame or image from the paste: only text, escaped await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } }); await h.save(); const [{ content }] = await h.saved(); // A bracket that survives is escaped (`\<`), so it is text on the next load. A link or image survives only with a // target the gate accepts: the two links and the one https image the paste carried (written as text, never // fetched), and no javascript: or data: target anywhere. assert.doesNotMatch(content.replace(/\\ m[1]).sort(), ['../docs/rel.md', 'https://ok.example/p', 'https://tracker.example/q.png']); assert.doesNotMatch(content, /(? { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'First para.'); p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, 0); r.setEnd(p.firstChild, 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`); await h.wait('(() => { const s = getSelection(); return s.toString() === "First"; })()'); await h.q('document.querySelector(".ed-btn[data-cmd=link]").click(), true'); await h.wait('!document.querySelector(".ed-linkbar").hidden'); const setLink = async target => { await h.q(`(() => { const i = document.querySelector('.ed-link-input'); i.value = ${JSON.stringify(target)}; i.form.requestSubmit(); return true; })()`); }; for (const bad of ['javascript:window.injected=30', 'JaVaScRiPt:window.injected=31', 'data:text/html,x', ' javascript:x', '//evil.example/x', 'vbscript:x', 'https://a b.example/']) { await setLink(bad); await h.wait('document.querySelector(".ed-link-note").textContent.includes("not allowed")'); assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror a").length'), 0, `${bad} made no link`); assert.equal(await h.q('document.querySelector(".ed-linkbar").hidden'), false, 'the bar stays open and says why'); await h.q('document.querySelector(".ed-link-note").textContent = "", true'); } await setLink('https://ok.example/page'); await h.wait('document.querySelectorAll("#doc-visual .ProseMirror a").length === 1'); assert.deepEqual(await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror a")].map(a => [a.getAttribute("href"), a.getAttribute("rel"), a.getAttribute("target"), a.textContent])'), [['https://ok.example/page', 'noopener noreferrer', '_blank', 'First']]); await h.quiet(); }); }); // Under Chromium 156 the paste raises one script-src-attr report per handler from the parse into prosemirror-view's // detached document, and the page record sets those aside (STUB). A handler on the live page must still fail quiet(): one // set on a paragraph in the editor and clicked, one parsed into an element the page owns but never attaches, one that // prosemirror-view itself sets on an editor it draws, so that report names editor.js at another line, and one parsed into // an element the page attaches and takes off again in the same turn. Each also says whether the page's MutationObserver // saw the handler enter the page (window.__live): it must for all but the element never attached, or quiet() could not // catch a pasted handler that was attached and removed before its CSP report was sent. test('after a hostile paste, an inline handler on the live page still fails the quiet check', { timeout: 120000 }, async () => { await session(async h => { await h.open('docs/mix.md'); await h.pm(); const HTML = '

pasted

' + '

a b

' + ''; await h.q(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); p.focus(); getSelection().selectAllChildren(p); getSelection().collapseToEnd(); const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(HTML)}); p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return true; })()`); await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")'); await h.q('new Promise(r => setTimeout(r, 300)).then(() => true)'); const view = await h.inert(); assert.deepEqual([view.bad, view.handlers, view.fetched, view.injected], [[], 0, [], null]); await h.quiet(); const LIVE = [ ['a handler set on a paragraph in the editor, then clicked', `(() => { const p = document.querySelector('#doc-visual .ProseMirror p'); p.setAttribute('onclick', 'window.injected=64'); p.click(); return true; })()`, true], ['a handler parsed into an element the page owns and never attaches', `(() => { document.createElement('div').innerHTML = ''; return true; })()`, false], ['a handler prosemirror-view sets through its attributes prop on an editor it draws, then clicked', `(() => { const host = document.body.appendChild(document.createElement('div')); const ed = window.MosaicEditor.mount(host, { text: 'x', editable: false }); ed.view.setProps({ attributes: { onclick: 'window.injected=66' } }); ed.view.dom.click(); return true; })()`, true], ['a handler parsed into an element the page attaches and takes off again in the same turn', `(() => { const d = document.createElement('div'); d.innerHTML = 'x'; document.body.appendChild(d); d.remove(); return true; })()`, true], ]; for (const [what, expr, entered] of LIVE) { const aside = await h.q('window.__cspInert.length'); await h.q('window.__csp = [], window.__live = [], true'); await h.q(expr); // the one report comes when the attribute is set (Chromium 156) or when the handler would fire (earlier releases) await h.wait(`window.__csp.length + window.__cspInert.length > ${aside}`); await assert.rejects(h.quiet(), e => { assert.deepEqual(e.actual, { csp: ['script-src-attr inline'], errors: [], rejections: [], injected: null }, what); return true; }, what); assert.equal(await h.q('window.__cspInert.length'), aside, `${what}: nothing set aside`); assert.equal(await h.q('window.__live.length > 0'), entered, `${what}: ${entered ? 'seen' : 'not seen'} entering the page`); } }); }); // Mos's text said an image becomes text; this row also gates a pasted image span (span.md-image[data-src], the editor's own // copy format) on the link gate, so a target the gate refuses never becomes an image node, and none can reach a save. test('a pasted image span is kept only for a target the link gate accepts, and a save writes only those', { timeout: 120000 }, async () => { await session(async h => { await h.open('docs/mix.md'); await h.pm(); const ACCEPTED = [['a', 'https://ok.example/a.png'], ['r', '../img/r.png']]; const REFUSED = ['data:image/png;base64,AAAA', 'javascript:window.injected=40', '//evil.example/i.png', ' javascript:window.injected=41', 'vbscript:x', 'https://a b.example/i.png', 'DATA:image/gif;base64,AAAA']; const HTML = [...ACCEPTED.map(([alt, src]) => `

kept-${alt} x

`), ...REFUSED.map((src, i) => `

refused-${i} x

`)].join(''); await h.q(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); p.focus(); getSelection().selectAllChildren(p); getSelection().collapseToEnd(); const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(HTML)}); p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return true; })()`); await h.wait(`document.querySelector("#doc-visual .ProseMirror").textContent.includes("refused-${REFUSED.length - 1}")`); // in the editor: an image node for each accepted target, drawn as text, and for no refused one const atoms = await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror .md-image")].map(e => [e.getAttribute("data-src"), e.getAttribute("data-alt"), e.localName, e.textContent])'); assert.deepEqual(atoms.map(a => a.slice(0, 3)), ACCEPTED.map(([alt, src]) => [src, alt, 'span']), 'image nodes for the accepted targets only'); assert.deepEqual(atoms.map(a => a[3]), ACCEPTED.map(([alt, src]) => `[image: ${alt}] ${src}`), 'drawn as text'); const refusedText = await h.q('document.querySelector("#doc-visual .ProseMirror").textContent'); for (let i = 0; i < REFUSED.length; i++) assert.ok(refusedText.includes(`refused-${i}`), `the text around refused target ${i} is kept`); for (const src of REFUSED) assert.ok(!refusedText.includes(src.trim()), `${src} is nowhere in the editor`); const view = await h.inert(); assert.deepEqual([view.bad, view.handlers, view.hrefs, view.fetched, view.injected], [[], 0, 0, [], null]); // a save: an image for each accepted target, written as markdown, and no refused target anywhere await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } }); await h.save(); const [{ content }] = await h.saved(); assert.deepEqual([...content.matchAll(/!\[([^\]]*)\]\(([^)\s]*)\)/g)].map(m => [m[1], m[2]]), ACCEPTED, 'saved images: the accepted targets, in order'); for (const src of REFUSED) assert.ok(!content.includes(src.trim()), `${src} is not in the saved file`); assert.doesNotMatch(content, /javascript:|vbscript:|data:image|evil\.example/i); for (let i = 0; i < REFUSED.length; i++) assert.ok(content.includes(`refused-${i}`), `the text around refused target ${i} is saved as text`); await h.quiet(); }); }); // A switch of view reuses the draft it holds; everything else that opens a file still reads it, once each. test('a switch of view reads nothing; a reload, and a trip away and back, each read the file exactly once', { timeout: 120000 }, async () => { await session(async h => { const settle = () => h.q('new Promise(r => setTimeout(r, 300)).then(() => true)'); const mix = () => h.reads.filter(p => p === 'docs/mix.md').length; await h.open('docs/mix.md'); await h.pm(); // a cold load renders twice (the page draws again when /api/config answers), so count from what opening cost await settle(); const opened = mix(); assert.ok(opened >= 1, 'opening reads the file'); for (let i = 0; i < 2; i++) { await h.q('document.querySelector("#doc-mode-source").click(), true'); await h.wait('document.querySelector("#doc-text")'); await h.q('document.querySelector("#doc-mode-visual").click(), true'); await h.pm(); } await settle(); assert.equal(mix(), opened, 'two round trips between the views read nothing'); // Reload from the project: one read, and a switch after it reads nothing more await h.q('document.querySelector("#doc-reload").click(), true'); await h.wait('document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")'); await h.pm(); await settle(); assert.equal(mix(), opened + 1, 'Reload reads once'); await h.q('document.querySelector("#doc-mode-source").click(), true'); await h.wait('document.querySelector("#doc-text")'); await settle(); assert.equal(mix(), opened + 1, 'a switch after Reload reads nothing'); // away and back: the draft is clean, so opening the file again reads it once await h.q('location.hash = "#/docs", true'); await h.wait('document.querySelector("#bus-view h1")?.textContent === "Docs" && !document.querySelector("#bus-view [aria-busy]")'); await h.open('docs/mix.md'); await settle(); assert.equal(mix(), opened + 2, 'going away and back reads once'); await h.quiet(); }); }); test('an edit rewrites only the block touched; a save sends the rest byte for byte; the Source view carries the draft',{ timeout: 120000 }, async () => { await session(async h => { await h.open('docs/mix.md'); await h.pm(); assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true, 'open with no edit: nothing to save'); // a table is drawn as a tinted source block, not a table assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror table").length'), 0); assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw").length'), 1); // type at the end of the first paragraph await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'First para.'); p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, p.firstChild.length); r.collapse(true); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`); await h.b.call('Input.insertText', { text: ' More **not bold**.' }); await h.wait('!document.querySelector("#doc-save").disabled'); // Save is on because the draft differs, so the editor must hold the typed text, and keep holding it until the click. const typed = () => h.q('document.querySelector("#doc-visual .ProseMirror").textContent'); assert.ok((await typed()).includes('First para. More **not bold**.'), 'the typed text is in the editor'); await h.q('new Promise(r => setTimeout(r, 300)).then(() => true)'); assert.equal(await h.q('document.querySelector("#doc-save").disabled'), false, 'Save stays on'); assert.ok((await typed()).includes('First para. More **not bold**.'), 'the typed text is still in the editor'); await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } }); await h.save(); const [sent] = await h.saved(); assert.deepEqual(Object.keys(sent).sort(), ['base', 'content', 'path', 'project'], 'the body row 148 sends'); assert.deepEqual([sent.project, sent.path, sent.base], ['stack', 'docs/mix.md', C1]); // the typed `**` is text, so it is escaped; the table, the heading and the last paragraph are untouched assert.equal(sent.content, '# Title\n\nFirst para. More \\*\\*not bold\\*\\*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n'); assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true, 'saved, so nothing left to save'); // the Source view shows the draft as the editor would save it, with no new read of the project, and an edit there // comes back to the visual view const readsBefore = h.reads.length; await h.q('document.querySelector("#doc-mode-source").click(), true'); await h.wait('document.querySelector("#doc-text")'); assert.equal(await h.q('document.querySelector("#doc-text").value'), sent.content); assert.equal(h.reads.length, readsBefore, 'a switch of view reads nothing'); await h.q(`(() => { const t = document.querySelector('#doc-text'); t.value = t.value.replace('Last para.', 'Final para.'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); await h.q('document.querySelector("#doc-mode-visual").click(), true'); await h.pm(); assert.ok((await h.q('document.querySelector("#doc-visual .ProseMirror").textContent')).includes('Final para.')); assert.equal(await h.q('document.querySelector("#doc-save").disabled'), false); assert.equal(h.reads.length, readsBefore, 'the switch back reads nothing either'); // toolbar and keys: bold with the key, a heading from the toolbar, a list from the markdown shortcut await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'Final para.'); p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, 0); r.setEnd(p.firstChild, 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`); await h.wait('getSelection().toString() === "Final"'); await h.q('document.querySelector(".ed-btn[data-cmd=strong]").click(), true'); await h.wait('document.querySelectorAll("#doc-visual .ProseMirror strong").length === 1'); await h.q('document.querySelector(".ed-btn[data-cmd=h2]").click(), true'); await h.wait('document.querySelectorAll("#doc-visual .ProseMirror h2").length === 1'); await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: '3'.repeat(40), at: '2026-10-16T02:00:00Z' } }); await h.save(); const second = (await h.saved())[1]; assert.equal(second.content, '# Title\n\nFirst para. More \\*\\*not bold\\*\\*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n## **Final** para.\n'); assert.equal(second.base, C2, 'the second save is based on the commit the first one made'); await h.quiet(); }); }); test('a file the editor cannot write back byte for byte opens as Source, says so, cannot save, and the Visual button works once it can', { timeout: 120000 }, async () => { await session(async h => { const settle = () => h.q('new Promise(r => setTimeout(r, 300)).then(() => true)'); const shown = () => h.q(`({ pm: !!document.querySelector('#doc-visual .ProseMirror'), text: document.querySelector('#doc-text')?.value ?? null, note: document.querySelector('#bus-view')?.textContent.includes('The visual editor is not available for this file, so it is open as source.'), pressed: [document.querySelector('#doc-mode-visual')?.getAttribute('aria-pressed'), document.querySelector('#doc-mode-source')?.getAttribute('aria-pressed')], saveOff: document.querySelector('#doc-save')?.disabled })`); const reload = async () => { await h.q('document.querySelector("#doc-reload").click(), true'); await h.wait('document.querySelector("#doc-commit")?.textContent !== "" && !document.querySelector("#bus-view [aria-busy]")'); await settle(); }; await h.open('docs/mix.md'); await h.pm(); assert.deepEqual((await shown()).pressed, ['true', 'false'], 'a plain file opens in the visual editor'); // roundTrip disagrees with the file: the gate refuses, whatever else the editor could do await h.q(`(() => { const real = window.MosaicEditor; window.__rt = false; window.MosaicEditor = { mount: real.mount, roundTrip: t => window.__rt ? real.roundTrip(t) : t + ' ' }; return true; })()`); await reload(); assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'a failed round trip shows the source, the note and a Save that is off'); // the screen shows Source, so the Visual button must act: once the file round-trips it opens the editor with no new read await h.q('window.__rt = true, true'); const reads = h.reads.length; await h.q('document.querySelector("#doc-mode-visual").click(), true'); await h.pm(); assert.deepEqual((await shown()).pressed, ['true', 'false'], 'Visual works from a fallback'); assert.equal(h.reads.length, reads, 'the switch reads nothing'); // the preference is not lost: the next fallback is a fallback again, not the Source the user never picked await h.q('window.__rt = false, true'); await reload(); assert.equal((await shown()).pm, false); // no editor script at all: the same page, the same note await h.q('window.MosaicEditor = undefined, true'); await reload(); assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'without window.MosaicEditor the page is Source with the note'); await h.q('document.querySelector("#doc-mode-visual").click(), true'); await settle(); assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'Visual with no editor stays Source'); await h.quiet(); }); }); // Dewey's r1 finding: a link title, an image alt or title and a code block's info string are written into one line of // Markdown, a paste can set any of them, and a line break in one saved a raw HTML block. Each of the four, on the paste path. // (The editor's own reading of a file can give a single line break in a title or alt, never a blank line; see BUILD.md.) const BREAK = 'T\n\n\n\n'; // Each test first waits for the pasted node, holding the hostile value, to be in the editor's document (read from its DOM), // so a paste that was dropped fails there and not as a missing string after Save. The
 comes between two paragraphs:
// prosemirror-view opens the first and last node of a pasted slice (Slice.maxOpen), so a lone 
 joins the block the
// cursor is in as plain text and sets no params; the paste here lands in the first block, the heading.
const attrOf = v => v.replace(/&/g, '&').replace(/"/g, '"');
// One paste, then Save: `value` is what the pasted node must hold in the document, `written` what the file must then contain.
// With `reread`, the saved text is also opened by the editor's own reader (a second, read-only mount): the title must come
// back as pasted and no tag may have come out as text of the document, which is what a renderer with raw HTML would run.
function pasteTest(name, html, value, written, node, attr, headline = `${name} pasted with line breaks in it is written on one line, so no HTML block comes of it`, reread = false) {
  test(headline, { timeout: 120000 }, async () => {
    await session(async h => {
      await h.open('docs/mix.md');
      await h.pm();
      await h.q(`(() => {
        const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
        getSelection().selectAllChildren(p); getSelection().collapseToEnd();
        const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(html)});
        p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
        return true; })()`);
      const sel = `#doc-visual .ProseMirror ${node}[${attr}]`;
      await h.wait(`document.querySelector(${JSON.stringify(sel)})`);
      assert.equal(await h.q(`document.querySelector(${JSON.stringify(sel)}).getAttribute(${JSON.stringify(attr)})`), value, 'the pasted node holds the hostile value in the document');
      await h.wait('!document.querySelector("#doc-save").disabled');
      await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
      await h.save();
      const [{ content }] = await h.saved();
      assert.ok(content.includes(written), `the value is written as ${JSON.stringify(written)} in ${JSON.stringify(content)}`);
      assert.doesNotMatch(content, /^[ \t]* {
          const host = document.createElement('div'); document.body.appendChild(host);
          const ed = window.MosaicEditor.mount(host, { text: ${JSON.stringify(content)}, editable: false });
          const titles = [];
          ed.view.state.doc.descendants(n => { if (n.type.name === 'image') titles.push(n.attrs.title); for (const m of n.marks) if (m.type.name === 'link') titles.push(m.attrs.title); });
          const text = ed.view.state.doc.textContent;
          ed.destroy(); host.remove();
          return { titles, text }; })()`);
        assert.deepEqual(back.titles, [value], 'the saved title reads back as it was pasted');
        assert.ok(!back.text.includes('word

`, '[word](https://ok.example/p "T ")', 'a[href="https://ok.example/p"]', 'title'], ['an image alt', `

x

`, '![T ](https://ok.example/a.png)', 'span.md-image', 'data-alt'], ['an image title', `

x

`, '![a](https://ok.example/a.png "T ")', 'span.md-image', 'data-title'], ['a code block info string', `

x

code

y

`, '```T\ncode\n```', 'pre[data-params]', 'data-params'], ]) pasteTest(name, html, BREAK, written, node, attr); // Dewey's r2 finding: the title writers escaped `"` and not `\`. A title `x\") ` then ended its quotes early // (the saved line re-read as a link titled `x\` followed by live inline HTML), and a title ending in `\` swallowed its own // closing quote. Both shapes, for a link title and an image title, on the paste path. Written, each backslash is doubled // and each quote gets its own backslash. const ESCAPE = 'x\\") '; const TRAILING = 'end\\'; for (const [what, value, written] of [['a quote after a backslash then a tag', ESCAPE, 'x\\\\\\") '], ['a trailing backslash', TRAILING, 'end\\\\']]) { pasteTest('a link title', `

word

`, value, `[word](https://ok.example/p "${written}")`, 'a[href="https://ok.example/p"]', 'title', `a link title holding ${what} is written with the backslash escaped, so the title stays closed`, true); pasteTest('an image title', `

x

`, value, `![a](https://ok.example/a.png "${written}")`, 'span.md-image', 'data-title', `an image title holding ${what} is written with the backslash escaped, so the title stays closed`, true); } // Filbert's g5: a backtick in a code block's info string ends the fence if written; the load side refuses it first (tilde // fence, below), so only a paste reaches the save side. pasteTest('a code block info string', '

x

code

y

', 'a`b', '```\ncode\n```', 'pre[data-params]', 'data-params', 'a code block info string with a backtick, pasted, is dropped on Save, so the fence stays closed'); test('a code block info string with a backtick, as a tilde fence can have, is kept as source, not rewritten without it', { timeout: 120000 }, async () => { await session(async h => { await h.open('docs/tilde.md'); await h.pm(); assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw").length'), 1, 'the block is a source block'); assert.ok((await h.q('document.querySelector("#doc-visual .ProseMirror pre.md-raw").textContent')).startsWith('~~~a`b')); await h.quiet(); }); });