Files
stack/packages/webui/tests/editor.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

76 lines
4.4 KiB
JavaScript

// Row 154 (#1648, Q168): the Console's visual editor, the parts that need no browser. The routes, the
// round trip (open and save with no edit writes the same bytes), and the rules the editor source keeps.
// The imported source and the build are editor-provenance.test.mjs's; the page is editor-browser.test.mjs's.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadEditor } from './helpers/editor-bundle.mjs';
import { EVERY } from './helpers/every-element.mjs';
import { fixture } from './fixture.mjs';
const webui = join(dirname(fileURLToPath(import.meta.url)), '..');
const repo = join(webui, '../..');
const editor = loadEditor();
test('the editor routes serve the built files with their types and no cache', async t => {
const f = await fixture();
t.after(() => f.close());
for (const [path, type] of [['/editor.js', 'text/javascript'], ['/editor.css', 'text/css']]) {
const response = await fetch(f.base + path);
assert.equal(response.status, 200, path);
assert.ok(response.headers.get('content-type').startsWith(type), path);
assert.equal(response.headers.get('cache-control'), 'no-store', path);
assert.equal(await response.text(), readFileSync(join(webui, 'src/public', path), 'utf8'), `${path} is the built file`);
}
const index = await (await fetch(f.base + '/')).text();
assert.match(index, /<link rel="stylesheet" href="\/editor\.css">/);
assert.ok(index.indexOf('src="/editor.js"') < index.indexOf('src="/bus.js"'), 'the editor loads before the page script, so bus.js can mount it');
});
test('open and save with no edit writes the same bytes: the goals review and a doc with every element', () => {
const goals = readFileSync(join(repo, 'docs/plans/2026-09-27_goals-review.md'), 'utf8');
assert.equal(editor.roundTrip(goals), goals);
assert.equal(editor.roundTrip(EVERY), EVERY);
// the same with the other line ending conventions a file can end in
for (const text of ['', '\n', '\n\n\n', 'no newline', '# T\n', '# T\n\n\n\ntext\n\n\n', '\n\n indented first line\n']) assert.equal(editor.roundTrip(text), text, JSON.stringify(text));
});
test('open and save with no edit writes the same bytes: every repository doc under docs/ and spec/', () => {
const found = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) { if (!['node_modules', '.git', 'v1'].includes(e.name)) walk(join(dir, e.name)); }
else if (e.name.endsWith('.md')) found.push(join(dir, e.name));
}
};
for (const d of ['docs', 'spec']) { try { walk(join(repo, d)); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
assert.ok(found.length > 20, `found ${found.length} docs`);
const changed = [];
for (const file of found) {
const text = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); // CRLF is saved as LF, as row 148's save does
if (editor.roundTrip(text) !== text) changed.push(file.slice(repo.length + 1));
}
assert.deepEqual(changed, [], 'files the editor would change on an open and save with no edit');
});
test('the editor source builds markup only with DOM calls, and the bundle has no eval', () => {
const sinks = /\binnerHTML\b|\bouterHTML\b|\binsertAdjacentHTML\b|\bdocument\.write\b|\beval\s*\(|\bnew\s+Function\b|\bsrcdoc\b|\bcreateContextualFragment\b|\bDOMParser\b/;
for (const file of ['entry.mjs', 'model.mjs', 'view.mjs']) {
const source = readFileSync(join(webui, 'editor', file), 'utf8').split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
assert.doesNotMatch(source, sinks, `editor/${file}`);
}
const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');
assert.doesNotMatch(bundle, /\beval\s*\(|\bnew\s+Function\b/, 'src/public/editor.js');
});
test('the link gate and the image rule are the preview\'s', () => {
const model = readFileSync(join(webui, 'editor/model.mjs'), 'utf8');
assert.match(model, /globalThis\.MosaicMarkdown\?\.linkTarget/, 'the link target goes through the one gate');
assert.doesNotMatch(model, /tag:\s*'img/, 'no schema rule makes an <img>');
// a file's link the gate refuses stays source text, byte for byte, on a round trip and an edit-free save
const hostile = '[a](javascript:alert(1)) [b](data:text/html,x) [c](vbscript:x) [d](//evil.example/x) [e](https://ok.example/)\n';
assert.equal(editor.roundTrip(hostile), hostile);
});