Files
stack/packages/webui/tests/docs-browser.test.mjs
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

258 lines
19 KiB
JavaScript

// Row 148 (#1642, ruling sage-0507): the spec and docs editor in a browser. A save passes only with
// the proxy's https Origin, which a loopback page can't have, so a script added before the page loads
// answers /api/config with edit on and answers each POST /api/project-file from a queue, recording
// what the page sent. The reads go to the real routes with stubbed bus answers. Server-side checks
// are project-file.test.mjs's; the preview's own rules are markdown-preview.test.mjs's.
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';
const C1 = '1'.repeat(40), C2 = '2'.repeat(40), C3 = '3'.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)', '',
'![pixel](https://tracker.example/p.png)', '',
'`<script>window.injected=5</script>`', '',
'```', '<iframe src="javascript:window.injected=6"></iframe>', '```', '',
].join('\n');
const files = { 'spec/north-star.md': HOSTILE, 'docs/lead.md': '\n\n indented first line\n', 'docs/plain.md': '# Plain\n\ntext\n' };
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__saves = []; window.__answers = []; window.__rejections = [];
addEventListener('unhandledrejection', e => window.__rejections.push(String(e.reason?.message ?? e.reason)));
// ?throwrender: the page's own preview script can't replace this one, so even the first paint throws.
if (location.search.includes('throwrender')) Object.defineProperty(window, 'MosaicMarkdown', { configurable: true, get: () => ({ render() { throw new Error('render failed'); }, linkTarget: () => null }), set() {} });
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({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
if (a.hold) await new Promise(r => { window.__release = r; });
if (a.reject) throw new TypeError('Failed to fetch');
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
};
})();`;
test('the editor: the list, hostile markdown that runs nothing, the draft, and each save receipt', { timeout: 120000 }, async () => {
const f = await fixture(), b = await browser();
const reads = { files: [], file: [] };
const web = await startServer({
port: 0, board: f.boardURL,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async args => { reads.files.push(args); return { head: C1, files: Object.keys(files).map(path => ({ path })) }; },
projectFile: async args => { reads.file.push(args); 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 text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`);
const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
const answer = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
const saves = () => b.evaluate('window.__saves');
// Row 154: a page opens in the visual editor; every row 148 check below drives the Source view.
const source = async () => {
await wait('document.querySelector("#doc-commit")?.textContent !== "" && document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")');
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
await wait('document.querySelector("#doc-text") && document.querySelector("#doc-commit") && document.querySelector("#doc-commit").textContent !== "" && !document.querySelector("#bus-view [aria-busy]")');
};
const open = async path => { await b.evaluate(`location.hash = '#/docs/stack/' + encodeURIComponent(${JSON.stringify(path)}), true`); await source(); };
const save = async () => { await b.evaluate('document.querySelector("#doc-save").click(), true'); await wait('document.querySelector("#doc-status").textContent !== "" && document.querySelector("#doc-status").textContent !== "Saving…"'); };
const PATH = 'spec/north-star.md';
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
await b.viewport(1280);
// The list: each project's spec and docs files, with the nav item.
await b.navigate(`${base}#/docs`);
await wait('document.querySelector("#bus-view h1")?.textContent === "Docs" && !document.querySelector("#bus-view [aria-busy]")');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view ul a")].map(a => [a.textContent, a.getAttribute("href")])'),
Object.keys(files).map(p => [p, `#/docs/stack/${encodeURIComponent(p)}`]));
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").textContent.trim()'), 'Docs');
await b.evaluate('document.querySelector("#bus-view ul a").click(), true');
await source();
assert.equal(await text('#bus-view h1'), `Edit ${PATH}`);
assert.deepEqual(reads.file, [{ project: 'stack', path: PATH }]);
// The source is the file as written; markdown in it runs nothing and builds no script, frame or image.
assert.equal(await value('#doc-text'), HOSTILE);
const preview = () => b.evaluate(`(() => { const p = document.querySelector('#doc-preview'); return {
bad: p.querySelectorAll('script, img, iframe, object, embed, style, link, form, svg, [onerror], [onclick]').length,
links: [...p.querySelectorAll('a')].map(a => [a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target'), a.textContent]),
target: [...p.querySelectorAll('.md-target')].map(e => e.textContent), text: p.textContent, injected: window.injected ?? null, jsHref: !!p.querySelector('a[href^="javascript" i], a[href^="data" i]') }; })()`);
const view = await preview();
assert.deepEqual([view.bad, view.injected, view.jsHref], [0, null, false]);
assert.deepEqual(view.links, [['https://example.com/a', 'noopener noreferrer', '_blank', 'ok']]);
assert.deepEqual(view.target, ['(https://example.com/a)']);
for (const shown of ['<script>window.injected=1</script>', '<img src=x onerror="window.injected=2">', '[click](javascript:window.injected=3)', '[image: pixel] https://tracker.example/p.png', '<iframe src="javascript:window.injected=6"></iframe>'])
assert.ok(view.text.includes(shown), shown);
// The same typed in: the preview follows the textarea and still runs nothing.
await set('#doc-text', `${HOSTILE}\n<svg onload="window.injected=7"></svg>\n[x](JaVaScRiPt:window.injected=8)\n`);
const typed = await preview();
assert.deepEqual([typed.bad, typed.injected, typed.jsHref], [0, null, false]);
assert.ok(typed.text.includes('<svg onload="window.injected=7"></svg>'));
await set('#doc-text', HOSTILE);
// A draft that equals the file is not savable; an edit is, and a save sends exactly what was typed.
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), true);
const edited = `${HOSTILE}\nA new line, typed.\n`;
await set('#doc-text', edited);
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), false);
assert.match(await text('#doc-commit'), new RegExp(`^${C1.slice(0, 12)}$`));
// The draft, focus and caret survive a refresh and a signed-in config answer arriving late.
await b.evaluate('(()=>{const e=document.querySelector("#doc-text");e.dataset.before="1";e.focus();e.setSelectionRange(3,9);document.querySelector("#refresh").click();return true})()');
await wait('!document.querySelector("#doc-text")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
assert.equal(await value('#doc-text'), edited);
assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#doc-text");return {focus:document.activeElement===e,start:e.selectionStart,end:e.selectionEnd}})()'), { focus: true, start: 3, end: 9 });
assert.equal(reads.file.length, 1, 'a draft is never read over');
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), false);
// A stale base: the page says so, keeps the draft and sends nothing more.
await answer({ status: 409, body: { error: 'stale-base', sent: false, message: 'not saved: the file changed since you opened it. Reload it, then make your edit again.' } });
await save();
assert.equal(await text('#doc-status'), 'not saved: the file changed since you opened it. Reload it, then make your edit again. Draft kept.');
assert.equal(await value('#doc-text'), edited);
assert.deepEqual((await saves()).map(s => JSON.parse(s.body)), [{ project: 'stack', path: PATH, base: C1, content: edited }]);
assert.deepEqual([(await saves())[0].method, (await saves())[0].headers, (await saves())[0].redirect], ['POST', { 'content-type': 'application/json' }, 'manual']);
// A lost answer and an unusable one are unknown: said so, the draft kept, nothing repeated.
for (const a of [{ reject: true }, { status: 200, body: { path: PATH } }, { status: 200, body: { path: 'docs/plain.md', commit: C2 } }, { status: 503, body: { error: 'outcome-unknown', sent: 'unknown', message: 'x' } }]) {
await answer(a);
await save();
assert.match(await text('#doc-status'), /^Outcome unknown: the save may have gone through\. Copy your text, then reload the file to check before saving again\. Draft kept\.$/, JSON.stringify(a));
assert.equal(await value('#doc-text'), edited);
assert.match(await text('#doc-commit'), new RegExp(`^${C1.slice(0, 12)}$`), 'the base does not move on an unknown outcome');
}
assert.equal((await saves()).length, 5);
// A refusal the user can fix keeps the draft, in the server's words.
await answer({ status: 409, body: { error: 'project-dirty', sent: false, message: 'not saved: the bus refused it (project-dirty).' } });
await save();
assert.equal(await text('#doc-status'), 'not saved: the bus refused it (project-dirty). Draft kept.');
// While a save is pending the button is off and a second press sends nothing; typing meanwhile is kept.
await answer({ hold: true, status: 200, body: { path: PATH, commit: C2, at: '2026-10-16T01:00:00Z' } });
await b.evaluate('document.querySelector("#doc-save").click(), true');
await wait('document.querySelector("#doc-status").textContent === "Saving…"');
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), true);
await b.evaluate('document.querySelector("#doc-form").requestSubmit(), true');
const later = `${edited}Typed while saving.\n`;
await set('#doc-text', later);
assert.equal((await saves()).length, 7);
await b.evaluate('window.__release(), true');
await wait('/^Saved as commit/.test(document.querySelector("#doc-status").textContent)');
assert.equal(await text('#doc-status'), `Saved as commit ${C2.slice(0, 12)}.`);
assert.match(await text('#doc-commit'), new RegExp(`^${C2.slice(0, 12)}$`));
assert.equal(await value('#doc-text'), later);
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), false, 'what was typed meanwhile is still unsaved');
// The next save names the new commit; saved text is not savable again until it changes.
await answer({ status: 200, body: { path: PATH, commit: C3, at: '2026-10-16T02:00:00Z' } });
await save();
assert.equal(JSON.parse((await saves())[7].body).base, C2);
assert.equal(JSON.parse((await saves())[7].body).content, later);
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), true);
// Reload discards the draft and reads the file again.
await set('#doc-text', 'something else');
await b.evaluate('document.querySelector("#doc-reload").click(), true');
await wait('document.querySelector("#doc-text") && document.querySelector("#doc-text").value === ' + JSON.stringify(HOSTILE));
assert.equal(reads.file.length, 2);
// A preview that throws shows a plain message, keeps the draft, and wedges nothing. The seam is the
// page's own window.MosaicMarkdown, replaced from outside; bus.js has no hook for this.
const FAILED = 'The preview failed. The source is unchanged.';
const disabled = () => b.evaluate('document.querySelector("#doc-save").disabled');
await b.evaluate('window.__real = window.MosaicMarkdown, window.MosaicMarkdown = { render() { throw new Error("render failed"); }, linkTarget() { return null; } }, true');
const broken = `${HOSTILE}\nTyped while the preview is broken.\n`;
await set('#doc-text', broken);
assert.equal(await text('#doc-preview'), FAILED);
assert.equal(await b.evaluate('document.querySelector("#doc-preview").children.length'), 0);
assert.equal(await value('#doc-text'), broken, 'the draft is kept');
assert.equal(await disabled(), false, 'the lines after the preview still ran');
await answer({ status: 200, body: { path: PATH, commit: C2, at: '2026-10-16T03:00:00Z' } });
await save();
assert.equal(await text('#doc-status'), `Saved as commit ${C2.slice(0, 12)}.`);
assert.equal(JSON.parse((await saves())[8].body).content, broken);
assert.equal(await disabled(), true);
await set('#doc-text', 'discarded by reload');
assert.equal(await text('#doc-preview'), FAILED);
await b.evaluate('document.querySelector("#doc-reload").click(), true');
await wait('document.querySelector("#doc-text") && document.querySelector("#doc-text").value === ' + JSON.stringify(HOSTILE) + ' && document.querySelector("#doc-commit").textContent !== ""');
assert.equal(await text('#doc-preview'), FAILED);
assert.deepEqual(await b.evaluate('window.__rejections'), [], 'a throwing preview rejects nothing');
await b.evaluate('window.MosaicMarkdown = window.__real, true');
await set('#doc-text', `${HOSTILE}back`);
assert.ok((await preview()).text.includes('<script>window.injected=1</script>'), 'the preview is back once the renderer is');
// A paint that throws on a later line, mid-save: nothing is sent, and Save and Reload still work.
await set('#doc-text', 'draft for a throwing paint');
await b.evaluate(`Object.defineProperty(document.getElementById('doc-status'), 'textContent', { configurable: true, set() { throw new Error('paint failed'); } }), true`);
await b.evaluate('document.querySelector("#doc-save").click(), true');
await wait('window.__rejections.length === 1');
assert.deepEqual(await b.evaluate('window.__rejections'), ['paint failed']);
await b.evaluate(`delete document.getElementById('doc-status').textContent, true`);
assert.equal((await saves()).length, 9, 'a save whose first paint threw sends nothing');
await set('#doc-text', 'draft for a throwing paint, again');
assert.equal(await disabled(), false, 'Save is not left off');
assert.equal(await value('#doc-text'), 'draft for a throwing paint, again');
await b.evaluate('document.querySelector("#doc-reload").click(), true');
await wait('document.querySelector("#doc-text") && document.querySelector("#doc-text").value === ' + JSON.stringify(HOSTILE));
assert.equal(reads.file.length, 4, 'Reload is not refused');
await b.evaluate('window.__rejections.length = 0, true');
// A file opened with a blank first line comes back whole: the textarea would drop one newline.
await open('docs/lead.md');
assert.equal(await value('#doc-text'), files['docs/lead.md']);
// Leaving a file with an unsaved draft keeps the draft; a clean file is read again on return.
await set('#doc-text', 'draft kept across pages');
await open('docs/plain.md');
assert.equal(await value('#doc-text'), files['docs/plain.md']);
await open('docs/lead.md');
assert.equal(await value('#doc-text'), 'draft kept across pages');
// A path outside the two the editor writes is not a page.
await b.evaluate(`location.hash = '#/docs/stack/' + encodeURIComponent('src/main.js'), true`);
await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
assert.match(await text('#bus-view'), /src\/main\.js is not a spec or docs file\./);
// The sign-in proxy's redirect signs the page out and keeps the draft.
await open(PATH);
await set('#doc-text', 'edited once more');
await answer({ redirect: true });
await b.evaluate('document.querySelector("#doc-save").click(), true');
await wait('!document.querySelector("#signed-out").hidden');
assert.equal(await text('#signed-out'), 'Signed out. Reload the page to sign in.');
assert.equal(await b.evaluate('!!document.querySelector("#doc-text")'), false);
// Without edit in /api/config the source is read-only and Save is off.
await b.navigate(`${base}?noedit#/docs/stack/${encodeURIComponent('docs/plain.md')}`);
await source();
assert.equal(await b.evaluate('document.querySelector("#doc-text").readOnly'), true);
assert.equal(await b.evaluate('document.querySelector("#doc-save").disabled'), true);
assert.match(await text('#bus-view'), /Saving needs a Console started with --send/);
assert.equal(await b.evaluate('window.__saves.length'), 0);
// The first paint of a page whose preview throws: the same message, the source shown, no unhandled rejection.
await b.navigate(`${base}?throwrender#/docs/stack/${encodeURIComponent(PATH)}`);
await source();
assert.equal(await text('#doc-preview'), FAILED);
assert.equal(await value('#doc-text'), HOSTILE);
assert.equal(await text('#doc-commit'), C1.slice(0, 12));
assert.deepEqual(await b.evaluate('window.__rejections'), []);
} finally {
await b.close(); await close(web); await f.close();
}
});