Row 148 round 3, Researcher's candidate (agents/researcher/work/queue-148/, row148.patch 1b63164b, manifest 95ae4736, 27 paths, base18aee8fa1). Dewey approved round 3 (comment 28832) and Filbert approved it with no findings (28835). Mos gave the decision 123 go on public diff 8360122b (mos-0636): no vendored code, no innerHTML or other HTML sinks. The Docs page #/docs lists each project's spec/north-star.md and the Markdown files directly under docs/. Save writes the typed text as one local commit authored by business.human, through a new human-only bus verb project.file.save that carries the opened commit as base and refuses stale-base if the file moved. The preview is a textarea plus a renderer built with createElement and textContent only; raw HTML shows as source text and images never load. CSP is unchanged. Round 3 replaced the backreference HR regex, which overflowed the regexp stack, with an isRule scan. Round 4 added a textContent fallback when paintDoc throws, and the submit handler resets saving in a finally. Mos's rule (ceiling 1 s at 1 MiB, growth x3 per doubling over 1 to 4 MiB): 130/130 and 138/138 cells pass, worst 513 ms at 1 MiB, worst ratio x2.50, control x2.01; the 756-cell grid has 0 timeouts. Mutants 43/44, v21 equivalent. Follow-ups: Vikunja P19 tasks 318 (Save stays off after a non-render paint throw), 319 (dead NUL check in bus.js) and 320 (the 1 MiB + 64 KiB buffer before the human proof, Gitea #1650). Sage's landing gate ran ona4779d2a5plus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), 22:19:24Z to 22:24:09Z, network sage-r148g_default removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
252 lines
19 KiB
JavaScript
252 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');
|
|
const open = async path => { await b.evaluate(`location.hash = '#/docs/stack/' + encodeURIComponent(${JSON.stringify(path)}), true`); await wait(`document.querySelector("#doc-text") && document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")`); };
|
|
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 wait('document.querySelector("#doc-text")');
|
|
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 wait('document.querySelector("#doc-text") && !document.querySelector("#bus-view [aria-busy]")');
|
|
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 wait('document.querySelector("#doc-text") && document.querySelector("#doc-commit")?.textContent !== "" && !document.querySelector("#bus-view [aria-busy]")');
|
|
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();
|
|
}
|
|
});
|