Files
stack/packages/webui/tests/editor-browser.test.mjs
T
jason.woltje 9353675d1c webui: a WYSIWYG Markdown editor in the Console docs view, built from reviewable source (row 154, Q168, #1648)
Jason's Q168: a visual editor for the Console's project documents,
held to row 148's rendering rules.

Library. ProseMirror with prosemirror-markdown and markdown-it, chosen
in the phase 1 library report (LIBRARY.md, REPORT.md in the packet).
packages/webui/third_party/editor/ holds 19 upstream packages as source,
byte for byte, each with its license, plus NOTICE and PROVENANCE.json.

Build. packages/webui/editor/ holds the model, view, entry and
build.mjs. esbuild 0.28.2 is an exact devDependency of packages/webui
with its own lock; editor/toolchain.mjs checks the version and the
binary's sha256 before any build. MOSAIC_REQUIRE_EDITOR_BUILD=1 turns a
missing or different esbuild into a failure, and the rebuild test then
checks the committed src/public/editor.js and editor.css match a fresh
build. The built bundle is committed under Mos's conditions
(mos-0755, mos-0765).

Console. The docs view gets Visual and Source modes. A save still
writes Markdown through the existing project write path, with the same
stale-base and read-only handling. A mode switch reuses the draft and
reads nothing from the project. Links and images keep row 148's
target rules, and no raw HTML runs. serve.mjs serves /editor.js and
/editor.css; index.html loads them.

Owner Researcher, brief docs/plans/2026-10-16_q168-wysiwyg-editor.md.
Round 5 is a rebase onto d88613bac (row 155); only docs/TOOLS.md,
webui/README.md, public/bus.js and serve.mjs differ from the round
before. Reviewers on candidate manifest 3a4bc21c...3b05: Dewey
(security) approve 29027, Filbert approve 29026. Mos's decision 123 go
for public.diff 902f0244 (handwritten part ea59079e): mos-0824. Sage gate
on 72c3fa598 with MOSAIC_REQUIRE_EDITOR_BUILD=1: GATE_PASSED 25/25
(node 2397, selftests 422), 22:07:29Z to 22:13:50Z; the rebuild test
ran and passed.

Nothing live.
2026-10-18 17:14:39 -05:00

504 lines
37 KiB
JavaScript

// 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 { 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', '',
'<script>window.injected=1</script>',
'<img src=x onerror="window.injected=2">', '',
'[click](javascript:window.injected=3) [data](data:text/html,<script>window.injected=4</script>) [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,<svg onload=window.injected=7>)', '',
'<svg onload="window.injected=8"></svg>', '',
'`<script>window.injected=5</script>`', '',
'```', '<iframe src="javascript:window.injected=6"></iframe>', '```', '',
].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' };
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__saves = []; window.__answers = []; window.__rejections = []; window.__csp = []; window.__errors = [];
addEventListener('unhandledrejection', e => window.__rejections.push(String(e.reason?.message ?? e.reason)));
addEventListener('securitypolicyviolation', e => window.__csp.push(e.violatedDirective + ' ' + e.blockedURI));
addEventListener('error', e => window.__errors.push(String(e.message)));
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 }),
// 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 <img class="ProseMirror-separator"> (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('<script>window.injected=1</script>') && 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 = [
'<p>pasted</p>',
'<script>window.injected=11</script>',
'<img src="https://tracker.example/p.png" onerror="window.injected=12">',
'<p><a href="javascript:window.injected=13" onclick="window.injected=14">js</a>',
'<a href="data:text/html,<script>window.injected=15</script>">data</a>',
'<a href=" javascript:window.injected=16">space</a>',
'<a href="//evil.example/x">proto-relative</a>',
'<a href="https://ok.example/p" onmouseover="window.injected=17">ok</a>',
'<a href="../docs/rel.md">rel</a></p>',
'<p><span class="md-image" data-src="data:image/png;base64,AAAA" data-alt="d">img</span>',
'<span class="md-image" data-src="javascript:window.injected=18" data-alt="j">img</span>',
'<span class="md-image" data-src="https://tracker.example/q.png" data-alt="t">img</span></p>',
'<iframe src="javascript:window.injected=19"></iframe><object data="https://evil.example/o"></object>',
'<svg onload="window.injected=20"><script>window.injected=21</script></svg>',
'<style>body{background:url(https://evil.example/css)}</style>',
'<form action="https://evil.example/f"><input name=x></form>',
'<div style="background:url(https://evil.example/bg)">styled</div>',
].join('');
await paste(HTML, '[x](javascript:window.injected=22) <script>window.injected=23</script>');
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) <script>window.injected=25</script> ![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(/\\</g, ''), /<(script|iframe|object|svg|style|form|img)/i, 'every angle bracket pasted is escaped');
assert.deepEqual([...content.matchAll(/(?<!\\)\]\(([^)\s]*)\)/g)].map(m => m[1]).sort(), ['../docs/rel.md', 'https://ok.example/p', 'https://tracker.example/q.png']);
assert.doesNotMatch(content, /(?<!\\)\]\(\s*(javascript|data):/i);
await h.quiet();
// the link bar: select a word, set a refused target, then an accepted one. Same URL and hash, so load about:blank first:
// the page must really reload, and the pasted draft must be gone before the link bar is tried.
await h.b.navigate('about:blank');
await h.open('docs/mix.md');
await h.pm();
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror a").length'), 0, 'a fresh page: the pasted links are gone');
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")'), false, 'a fresh page: the pasted text is gone');
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, 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();
});
});
// 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]) => `<p>kept-${alt} <span class="md-image" data-src="${src}" data-alt="${alt}">x</span></p>`),
...REFUSED.map((src, i) => `<p>refused-${i} <span class="md-image" data-src="${src}" data-alt="n${i}">x</span></p>`)].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<img src=x onerror=alert(1)>\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 <pre> comes between two paragraphs:
// prosemirror-view opens the first and last node of a pasted slice (Slice.maxOpen), so a lone <pre> 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, '&amp;').replace(/"/g, '&quot;');
// 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]*<img/m, 'no line of the file starts an HTML block');
assert.doesNotMatch(content, /\n[ \t]*\n[ \t]*<img/, 'no blank line then a tag');
if (reread) {
const back = await h.q(`(() => {
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('<img'), `no tag in the text of the re-read document: ${JSON.stringify(back.text)}`);
}
await h.quiet();
});
});
}
for (const [name, html, written, node, attr] of [
['a link title', `<p><a href="https://ok.example/p" title="${BREAK}">word</a></p>`, '[word](https://ok.example/p "T <img src=x onerror=alert(1)> ")', 'a[href="https://ok.example/p"]', 'title'],
['an image alt', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="${BREAK}">x</span></p>`, '![T <img src=x onerror=alert(1)> ](https://ok.example/a.png)', 'span.md-image', 'data-alt'],
['an image title', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="a" data-title="${BREAK}">x</span></p>`, '![a](https://ok.example/a.png "T <img src=x onerror=alert(1)> ")', 'span.md-image', 'data-title'],
['a code block info string', `<p>x</p><pre data-params="${BREAK}">code</pre><p>y</p>`, '```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\") <img ...>` 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\\") <img src=x onerror=alert(1)> ';
const TRAILING = 'end\\';
for (const [what, value, written] of [['a quote after a backslash then a tag', ESCAPE, 'x\\\\\\") <img src=x onerror=alert(1)> '], ['a trailing backslash', TRAILING, 'end\\\\']]) {
pasteTest('a link title', `<p><a href="https://ok.example/p" title="${attrOf(value)}">word</a></p>`, 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', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="a" data-title="${attrOf(value)}">x</span></p>`, 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', '<p>x</p><pre data-params="a`b">code</pre><p>y</p>', '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();
});
});