// 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', '', '', '', '', '[click](javascript:window.injected=3) [data](data:text/html,) [ok](https://example.com/a)', '', '![pixel](https://tracker.example/p.png)', '', '``', '', '```', '', '```', '', ].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 ['', '', '[click](javascript:window.injected=3)', '[image: pixel] https://tracker.example/p.png', '']) 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\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('')); 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(''), '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(); } });