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 ontod88613bac(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 on72c3fa598with 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.
258 lines
19 KiB
JavaScript
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)', '',
|
|
'', '',
|
|
'`<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();
|
|
}
|
|
});
|