// 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/)', '',
'', '',
'', '',
' ', '',
'``', '',
'```', '', '```', '',
].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) => "" + 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) ');
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
`, '', 'span.md-image', 'data-alt'],
['an image title', `x
`, '', '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, ``, '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();
});
});