Chromium also targets the document when a handler-bearing element is attached to the live page and removed before the CSP event is sent, so the readHTML filter alone (directive, source file, line, target document) could not tell that from the inert parse. The page now also records, with a MutationObserver on the live document, every element that enters the page carrying an on* attribute and every on* attribute set on an element in the page, and h.quiet() asserts the record is empty. The live-handler test gains a case for a handler attached and removed in the same turn, and checks per case that the observer saw the handler enter the page (not for the element never attached). Test-only; no product change.
591 lines
45 KiB
JavaScript
591 lines
45 KiB
JavaScript
// Row 154 (#1648, Q168): the visual editor in a browser, under the Console's own Content-Security-Policy.
|
|
// Same harness as docs-browser.test.mjs: a script added before the page loads answers /api/config with edit
|
|
// on and records each POST /api/project-file, and the reads go to the real routes with stubbed bus answers.
|
|
// Row 148's checks (the Source view, the save receipts) are docs-browser.test.mjs's and run unchanged there.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { browser } from './browser.mjs';
|
|
import { fixture, close } from './fixture.mjs';
|
|
import { EVERY } from './helpers/every-element.mjs';
|
|
|
|
const C1 = '1'.repeat(40), C2 = '2'.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)', '',
|
|
'[evil](//evil.example/x)', '',
|
|
'[sp](https://a b.example/)', '',
|
|
'', '',
|
|
'', '',
|
|
'<svg onload="window.injected=8"></svg>', '',
|
|
'`<script>window.injected=5</script>`', '',
|
|
'```', '<iframe src="javascript:window.injected=6"></iframe>', '```', '',
|
|
].join('\n');
|
|
const MIX = '# Title\n\nFirst para.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n';
|
|
const files = { 'docs/hostile.md': HOSTILE, 'docs/every.md': EVERY, 'docs/mix.md': MIX, 'docs/empty.md': '', 'docs/tilde.md': '~~~a`b\ncode\n~~~\n' };
|
|
|
|
// Chromium 156 checks script-src-attr when an inline handler attribute is created, not when the handler would run, and does
|
|
// so in a document with no window as well (WPT PR 62762; PR 64290 later skips a windowless document, and 156 lacks it). So
|
|
// prosemirror-view's parse of pasted HTML into a fresh createHTMLDocument raises one report per handler against the page's
|
|
// policy, though no handler there can run. The page record sets aside only reports whose event names, as the place the
|
|
// attribute was created, the one statement below in editor.js, and whose target is the document. That filter alone cannot
|
|
// tell a handler that stayed in the detached document from one that was attached to the live page and taken off again
|
|
// before the report was sent: Chromium then also targets the document. So the page also records, with a MutationObserver
|
|
// on the live document that does not look at CSP events, every element that enters the page carrying an on* attribute and
|
|
// every on* attribute set on an element in the page (STUB, window.__live; quiet() asserts it stays empty). Mutation records
|
|
// outlast an attach and a removal in one turn. A handler put on a live element by other code also reports another file or
|
|
// line, and a release that checks only when a handler runs reports where it runs, never at this parse. INERT_PARSE pins the
|
|
// lines that make `elt` the body of a document from detachedDoc(), and DETACHED pins that detachedDoc() makes a new
|
|
// createHTMLDocument each call.
|
|
const BUNDLE = readFileSync(new URL('../src/public/editor.js', import.meta.url), 'utf8').split('\n');
|
|
const INERT_PARSE = [
|
|
' function readHTML(html) {',
|
|
' let metas = /^(\\s*<meta [^>]*>)*/.exec(html);',
|
|
' if (metas) html = html.slice(metas[0].length);',
|
|
' let doc3 = detachedDoc(), elt = doc3.body;',
|
|
' let firstTag = /<([a-z][^>\\s]+)/i.exec(html), wrap2;',
|
|
' if (wrap2 = firstTag && wrapMap[firstTag[1].toLowerCase()])',
|
|
' html = wrap2.map((n) => "<" + n + ">").join("") + html + wrap2.map((n) => "</" + n + ">").reverse().join("");',
|
|
' elt.innerHTML = maybeWrapTrusted(html);',
|
|
];
|
|
const DETACHED = [' function detachedDoc() {', ' return document.implementation.createHTMLDocument("title");', ' }'];
|
|
const found = lines => BUNDLE.flatMap((l, i) => lines.every((s, j) => BUNDLE[i + j] === s) ? [i] : []);
|
|
assert.ok(found(INERT_PARSE).length === 1 && found(DETACHED).length === 1 && BUNDLE.filter(l => /\bdetachedDoc\s*=|function detachedDoc\b/.test(l)).length === 1,
|
|
'editor.js parses pasted HTML in readHTML into the body of a new createHTMLDocument; the CSP record below is keyed on that line');
|
|
const INERT_PARSE_LINE = found(INERT_PARSE)[0] + INERT_PARSE.length;
|
|
|
|
const STUB = `(() => {
|
|
const real = window.fetch.bind(window);
|
|
window.__saves = []; window.__answers = []; window.__rejections = []; window.__csp = []; window.__cspInert = []; window.__errors = [];
|
|
addEventListener('unhandledrejection', e => window.__rejections.push(String(e.reason?.message ?? e.reason)));
|
|
addEventListener('securitypolicyviolation', e => (e.violatedDirective === 'script-src-attr' && e.sourceFile === location.origin + '/editor.js'
|
|
&& e.lineNumber === ${INERT_PARSE_LINE} && e.target === document ? window.__cspInert : window.__csp).push(e.violatedDirective + ' ' + e.blockedURI));
|
|
addEventListener('error', e => window.__errors.push(String(e.message)));
|
|
window.__live = [];
|
|
const handlers = el => [...el.attributes].map(a => a.name).filter(n => /^on/i.test(n));
|
|
new MutationObserver(records => { for (const r of records) {
|
|
if (r.type === 'attributes' && /^on/i.test(r.attributeName)) window.__live.push('set ' + r.attributeName + ' on ' + r.target.localName);
|
|
for (const n of r.addedNodes) if (n.nodeType === 1) for (const e of [n, ...n.querySelectorAll('*')]) if (handlers(e).length) window.__live.push('added ' + e.localName + ' with ' + handlers(e));
|
|
} }).observe(document, { subtree: true, childList: true, attributes: true });
|
|
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({ body: init.body });
|
|
const a = window.__answers.shift();
|
|
if (!a) throw new TypeError('no answer queued');
|
|
return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
|
|
};
|
|
})();`;
|
|
|
|
// One page over the fixture server, with the helpers every test here uses.
|
|
async function session(run) {
|
|
const reads = [];
|
|
const f = await fixture(), b = await browser();
|
|
const web = await startServer({
|
|
port: 0, board: f.boardURL,
|
|
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
|
|
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }),
|
|
projectFile: async args => { reads.push(args.path); 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 h = {
|
|
b, base, wait,
|
|
reads,
|
|
q: expr => b.evaluate(expr),
|
|
open: async (path, query = '') => {
|
|
await b.navigate(`${base}${query}#/docs/stack/${encodeURIComponent(path)}`);
|
|
await wait('document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")');
|
|
},
|
|
pm: () => wait('document.querySelector("#doc-visual .ProseMirror")'),
|
|
answer: a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`),
|
|
// What the page holds that must stay empty, whatever else a test does.
|
|
quiet: async () => {
|
|
assert.deepEqual(await b.evaluate('({ csp: window.__csp, errors: window.__errors, rejections: window.__rejections, injected: window.injected ?? null })'),
|
|
{ csp: [], errors: [], rejections: [], injected: null });
|
|
// read apart from the CSP record: the filter on it cannot tell a handler that entered the page and left again
|
|
assert.deepEqual(await b.evaluate('window.__live'), [], 'no element with an inline handler entered the live page');
|
|
},
|
|
// The editor's inert-ness: nothing in it can run or load.
|
|
inert: () => b.evaluate(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); return {
|
|
// prosemirror-view's own src-less <img class="ProseMirror-separator"> (a cursor hack after a non-editable node) is not content
|
|
bad: [...p.querySelectorAll('script, img, iframe, object, embed, style, link, form, svg, video, audio, source, base, meta')].filter(e => !e.matches('img.ProseMirror-separator:not([src])')).map(e => e.localName),
|
|
handlers: [...p.querySelectorAll('*')].filter(e => [...e.attributes].some(a => /^on/i.test(a.name))).length,
|
|
links: [...p.querySelectorAll('a')].map(a => [a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target')]),
|
|
hrefs: [...p.querySelectorAll('[href], [src], [srcset], [data], [action], [formaction]')].filter(e => e.localName !== 'a').length,
|
|
fetched: performance.getEntriesByType('resource').map(r => r.name).filter(n => /tracker\\.example|evil\\.example/.test(n)),
|
|
injected: window.injected ?? null }; })()`),
|
|
save: async () => {
|
|
await b.evaluate('document.querySelector("#doc-save").click(), true');
|
|
await wait('document.querySelector("#doc-status").textContent.startsWith("Saved")');
|
|
},
|
|
saved: async () => (await b.evaluate('window.__saves')).map(s => JSON.parse(s.body)),
|
|
done: async () => { await b.close(); await close(web); await f.close(); },
|
|
};
|
|
try { await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.viewport(1280); await run(h); } finally { await h.done(); }
|
|
}
|
|
|
|
test('the Console\'s CSP stands and the editor runs under it; hostile markdown on the load path runs and loads nothing', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
const csp = (await fetch(h.base)).headers.get('content-security-policy');
|
|
assert.match(csp, /(^|; )script-src 'self'(;|$)/);
|
|
assert.doesNotMatch(csp, /unsafe-eval|script-src[^;]*unsafe-inline|data:|blob:/);
|
|
|
|
await h.open('docs/hostile.md');
|
|
await h.pm();
|
|
assert.equal(await h.q('document.querySelector("#doc-mode-visual").getAttribute("aria-pressed")'), 'true', 'a page opens in the visual editor');
|
|
assert.equal(await h.q('!!document.querySelector("#doc-text")'), false);
|
|
assert.equal(await h.q('typeof window.MosaicEditor.mount'), 'function', 'editor.js ran under script-src self');
|
|
assert.equal(await h.q('getComputedStyle(document.querySelector(".ed-toolbar")).display'), 'flex', 'editor.css applied');
|
|
const view = await h.inert();
|
|
assert.deepEqual(view.bad, []);
|
|
assert.equal(view.handlers, 0);
|
|
assert.equal(view.hrefs, 0);
|
|
assert.deepEqual(view.fetched, []);
|
|
assert.equal(view.injected, null);
|
|
// markdown-it itself makes no link of a javascript: or a data: target, so those stay text; the paragraph holding them
|
|
// is not a refused block, and its one good link is a real link. A target markdown-it accepts but the gate refuses
|
|
// (`//evil.example/x`) makes its whole block a source block; a target with a space is no link to markdown-it at all.
|
|
assert.deepEqual(view.links, [['https://example.com/a', 'noopener noreferrer', '_blank']], 'the one good link is the only anchor on the page');
|
|
const shown = await h.q('document.querySelector("#doc-visual .ProseMirror").textContent');
|
|
assert.ok(shown.includes('<script>window.injected=1</script>') && shown.includes('javascript:window.injected=3') && shown.includes('data:text/html') && shown.includes('https://tracker.example/p.png'), 'what the file says shows as text');
|
|
assert.equal(await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw")].some(e => e.textContent.includes("[evil](//evil.example/x)"))'), true, 'a target the gate refuses leaves its block as source text');
|
|
assert.ok(shown.includes('[sp](https://a b.example/)'), 'a target with a space stays text');
|
|
// typing works under the policy: an edit changes the draft and turns Save on
|
|
await h.q('(() => { const p = document.querySelector("#doc-visual .ProseMirror"); p.focus(); getSelection().selectAllChildren(p); getSelection().collapseToEnd(); return true; })()');
|
|
await h.b.call('Input.insertText', { text: 'typed' });
|
|
await h.wait('!document.querySelector("#doc-save").disabled');
|
|
await h.quiet();
|
|
|
|
// a doc with every element: nothing in it runs, and a file's own links the gate accepts are real links
|
|
await h.open('docs/every.md');
|
|
await h.pm();
|
|
const every = await h.inert();
|
|
assert.deepEqual([every.bad, every.handlers, every.hrefs, every.fetched, every.injected], [[], 0, 0, [], null]);
|
|
for (const [href, rel, target] of every.links) {
|
|
assert.match(href, /^(https:\/\/|http:\/\/|[^:]*$)/, `a link target the gate accepts: ${href}`);
|
|
assert.equal(rel, 'noopener noreferrer');
|
|
assert.equal(target, '_blank');
|
|
}
|
|
assert.ok(every.links.some(([href]) => href === 'https://example.com/a'), 'a plain https link is a link');
|
|
assert.equal(await h.q('!document.querySelector("#doc-visual .ProseMirror a[href^=javascript i], #doc-visual .ProseMirror a[href^=data i]")'), true);
|
|
// open and save with no edit: nothing to save, and the editor holds the file's own bytes
|
|
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true);
|
|
assert.equal(await h.q(`window.MosaicEditor.roundTrip(${JSON.stringify(files['docs/every.md'])}) === ${JSON.stringify(files['docs/every.md'])}`), true);
|
|
await h.quiet();
|
|
|
|
// an empty file opens, and the same page read-only has no toolbar, no editing and no save
|
|
await h.open('docs/empty.md');
|
|
await h.pm();
|
|
await h.open('docs/mix.md', '?noedit');
|
|
await h.pm();
|
|
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror").getAttribute("contenteditable")'), 'false');
|
|
assert.equal(await h.q('!!document.querySelector(".ed-toolbar")'), false);
|
|
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true);
|
|
await h.quiet();
|
|
});
|
|
});
|
|
|
|
test('hostile content pasted into the editor runs and loads nothing, and a bad link target is refused in the link bar', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
const paste = (html, plain) => h.q(`(() => {
|
|
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
|
|
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
|
|
const dt = new DataTransfer();
|
|
${html == null ? '' : `dt.setData('text/html', ${JSON.stringify(html)});`}
|
|
${plain == null ? '' : `dt.setData('text/plain', ${JSON.stringify(plain)});`}
|
|
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
return true; })()`);
|
|
const HTML = [
|
|
'<p>pasted</p>',
|
|
'<script>window.injected=11</script>',
|
|
'<img src="https://tracker.example/p.png" onerror="window.injected=12">',
|
|
'<p><a href="javascript:window.injected=13" onclick="window.injected=14">js</a>',
|
|
'<a href="data:text/html,<script>window.injected=15</script>">data</a>',
|
|
'<a href=" javascript:window.injected=16">space</a>',
|
|
'<a href="//evil.example/x">proto-relative</a>',
|
|
'<a href="https://ok.example/p" onmouseover="window.injected=17">ok</a>',
|
|
'<a href="../docs/rel.md">rel</a></p>',
|
|
'<p><span class="md-image" data-src="data:image/png;base64,AAAA" data-alt="d">img</span>',
|
|
'<span class="md-image" data-src="javascript:window.injected=18" data-alt="j">img</span>',
|
|
'<span class="md-image" data-src="https://tracker.example/q.png" data-alt="t">img</span></p>',
|
|
'<iframe src="javascript:window.injected=19"></iframe><object data="https://evil.example/o"></object>',
|
|
'<svg onload="window.injected=20"><script>window.injected=21</script></svg>',
|
|
'<style>body{background:url(https://evil.example/css)}</style>',
|
|
'<form action="https://evil.example/f"><input name=x></form>',
|
|
'<div style="background:url(https://evil.example/bg)">styled</div>',
|
|
].join('');
|
|
await paste(HTML, '[x](javascript:window.injected=22) <script>window.injected=23</script>');
|
|
await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")');
|
|
const view = await h.inert();
|
|
assert.deepEqual(view.bad, [], 'no script, image, frame, object, style or form came in');
|
|
assert.equal(view.handlers, 0);
|
|
assert.equal(view.hrefs, 0);
|
|
assert.deepEqual(view.fetched, []);
|
|
assert.equal(view.injected, null);
|
|
assert.deepEqual(view.links.map(l => l[0]).sort(), ['../docs/rel.md', 'https://ok.example/p'], 'only targets the gate accepts became links');
|
|
for (const [, rel, target] of view.links) assert.deepEqual([rel, target], ['noopener noreferrer', '_blank']);
|
|
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror [style*=url]")'), null, 'no style attribute came in');
|
|
// pasting plain text that looks like markup or a link leaves it as text
|
|
await paste(null, '[x](javascript:window.injected=24) <script>window.injected=25</script> ');
|
|
await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("window.injected=25")');
|
|
const plain = await h.inert();
|
|
assert.deepEqual([plain.bad, plain.handlers, plain.fetched, plain.injected], [[], 0, [], null]);
|
|
assert.ok(!plain.links.some(l => /javascript/i.test(l[0])));
|
|
// what the save writes holds no live script, frame or image from the paste: only text, escaped
|
|
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
|
|
await h.save();
|
|
const [{ content }] = await h.saved();
|
|
// A bracket that survives is escaped (`\<`), so it is text on the next load. A link or image survives only with a
|
|
// target the gate accepts: the two links and the one https image the paste carried (written as text, never
|
|
// fetched), and no javascript: or data: target anywhere.
|
|
assert.doesNotMatch(content.replace(/\\</g, ''), /<(script|iframe|object|svg|style|form|img)/i, 'every angle bracket pasted is escaped');
|
|
assert.deepEqual([...content.matchAll(/(?<!\\)\]\(([^)\s]*)\)/g)].map(m => m[1]).sort(), ['../docs/rel.md', 'https://ok.example/p', 'https://tracker.example/q.png']);
|
|
assert.doesNotMatch(content, /(?<!\\)\]\(\s*(javascript|data):/i);
|
|
await h.quiet();
|
|
|
|
// the link bar: select a word, set a refused target, then an accepted one. Same URL and hash, so load about:blank first:
|
|
// the page must really reload, and the pasted draft must be gone before the link bar is tried.
|
|
await h.b.navigate('about:blank');
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror a").length'), 0, 'a fresh page: the pasted links are gone');
|
|
assert.equal(await h.q('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")'), false, 'a fresh page: the pasted text is gone');
|
|
await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'First para.');
|
|
p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, 0); r.setEnd(p.firstChild, 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`);
|
|
await h.wait('(() => { const s = getSelection(); return s.toString() === "First"; })()');
|
|
await h.q('document.querySelector(".ed-btn[data-cmd=link]").click(), true');
|
|
await h.wait('!document.querySelector(".ed-linkbar").hidden');
|
|
const setLink = async target => {
|
|
await h.q(`(() => { const i = document.querySelector('.ed-link-input'); i.value = ${JSON.stringify(target)}; i.form.requestSubmit(); return true; })()`);
|
|
};
|
|
for (const bad of ['javascript:window.injected=30', 'JaVaScRiPt:window.injected=31', 'data:text/html,x', ' javascript:x', '//evil.example/x', 'vbscript:x', 'https://a b.example/']) {
|
|
await setLink(bad);
|
|
await h.wait('document.querySelector(".ed-link-note").textContent.includes("not allowed")');
|
|
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror a").length'), 0, `${bad} made no link`);
|
|
assert.equal(await h.q('document.querySelector(".ed-linkbar").hidden'), false, 'the bar stays open and says why');
|
|
await h.q('document.querySelector(".ed-link-note").textContent = "", true');
|
|
}
|
|
await setLink('https://ok.example/page');
|
|
await h.wait('document.querySelectorAll("#doc-visual .ProseMirror a").length === 1');
|
|
assert.deepEqual(await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror a")].map(a => [a.getAttribute("href"), a.getAttribute("rel"), a.getAttribute("target"), a.textContent])'),
|
|
[['https://ok.example/page', 'noopener noreferrer', '_blank', 'First']]);
|
|
await h.quiet();
|
|
});
|
|
});
|
|
|
|
// Under Chromium 156 the paste raises one script-src-attr report per handler from the parse into prosemirror-view's
|
|
// detached document, and the page record sets those aside (STUB). A handler on the live page must still fail quiet(): one
|
|
// set on a paragraph in the editor and clicked, one parsed into an element the page owns but never attaches, one that
|
|
// prosemirror-view itself sets on an editor it draws, so that report names editor.js at another line, and one parsed into
|
|
// an element the page attaches and takes off again in the same turn. Each also says whether the page's MutationObserver
|
|
// saw the handler enter the page (window.__live): it must for all but the element never attached, or quiet() could not
|
|
// catch a pasted handler that was attached and removed before its CSP report was sent.
|
|
test('after a hostile paste, an inline handler on the live page still fails the quiet check', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
const HTML = '<p>pasted</p><img src="https://tracker.example/p.png" onerror="window.injected=60">' +
|
|
'<p><a href="https://ok.example/p" onclick="window.injected=61">a</a> <a href="https://ok.example/q" onmouseover="window.injected=62">b</a></p>' +
|
|
'<svg onload="window.injected=63"></svg>';
|
|
await h.q(`(() => {
|
|
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
|
|
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
|
|
const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(HTML)});
|
|
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
return true; })()`);
|
|
await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")');
|
|
await h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
|
|
const view = await h.inert();
|
|
assert.deepEqual([view.bad, view.handlers, view.fetched, view.injected], [[], 0, [], null]);
|
|
await h.quiet();
|
|
const LIVE = [
|
|
['a handler set on a paragraph in the editor, then clicked', `(() => { const p = document.querySelector('#doc-visual .ProseMirror p'); p.setAttribute('onclick', 'window.injected=64'); p.click(); return true; })()`, true],
|
|
['a handler parsed into an element the page owns and never attaches', `(() => { document.createElement('div').innerHTML = '<img src="/nothing.png" onerror="window.injected=65">'; return true; })()`, false],
|
|
['a handler prosemirror-view sets through its attributes prop on an editor it draws, then clicked', `(() => { const host = document.body.appendChild(document.createElement('div'));
|
|
const ed = window.MosaicEditor.mount(host, { text: 'x', editable: false }); ed.view.setProps({ attributes: { onclick: 'window.injected=66' } }); ed.view.dom.click(); return true; })()`, true],
|
|
['a handler parsed into an element the page attaches and takes off again in the same turn', `(() => { const d = document.createElement('div'); d.innerHTML = '<span onmouseover="window.injected=67">x</span>'; document.body.appendChild(d); d.remove(); return true; })()`, true],
|
|
];
|
|
for (const [what, expr, entered] of LIVE) {
|
|
const aside = await h.q('window.__cspInert.length');
|
|
await h.q('window.__csp = [], window.__live = [], true');
|
|
await h.q(expr);
|
|
// the one report comes when the attribute is set (Chromium 156) or when the handler would fire (earlier releases)
|
|
await h.wait(`window.__csp.length + window.__cspInert.length > ${aside}`);
|
|
await assert.rejects(h.quiet(), e => { assert.deepEqual(e.actual, { csp: ['script-src-attr inline'], errors: [], rejections: [], injected: null }, what); return true; }, what);
|
|
assert.equal(await h.q('window.__cspInert.length'), aside, `${what}: nothing set aside`);
|
|
assert.equal(await h.q('window.__live.length > 0'), entered, `${what}: ${entered ? 'seen' : 'not seen'} entering the page`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Mos's text said an image becomes text; this row also gates a pasted image span (span.md-image[data-src], the editor's own
|
|
// copy format) on the link gate, so a target the gate refuses never becomes an image node, and none can reach a save.
|
|
test('a pasted image span is kept only for a target the link gate accepts, and a save writes only those', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
const ACCEPTED = [['a', 'https://ok.example/a.png'], ['r', '../img/r.png']];
|
|
const REFUSED = ['data:image/png;base64,AAAA', 'javascript:window.injected=40', '//evil.example/i.png', ' javascript:window.injected=41', 'vbscript:x', 'https://a b.example/i.png', 'DATA:image/gif;base64,AAAA'];
|
|
const HTML = [...ACCEPTED.map(([alt, src]) => `<p>kept-${alt} <span class="md-image" data-src="${src}" data-alt="${alt}">x</span></p>`),
|
|
...REFUSED.map((src, i) => `<p>refused-${i} <span class="md-image" data-src="${src}" data-alt="n${i}">x</span></p>`)].join('');
|
|
await h.q(`(() => {
|
|
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
|
|
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
|
|
const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(HTML)});
|
|
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
return true; })()`);
|
|
await h.wait(`document.querySelector("#doc-visual .ProseMirror").textContent.includes("refused-${REFUSED.length - 1}")`);
|
|
// in the editor: an image node for each accepted target, drawn as text, and for no refused one
|
|
const atoms = await h.q('[...document.querySelectorAll("#doc-visual .ProseMirror .md-image")].map(e => [e.getAttribute("data-src"), e.getAttribute("data-alt"), e.localName, e.textContent])');
|
|
assert.deepEqual(atoms.map(a => a.slice(0, 3)), ACCEPTED.map(([alt, src]) => [src, alt, 'span']), 'image nodes for the accepted targets only');
|
|
assert.deepEqual(atoms.map(a => a[3]), ACCEPTED.map(([alt, src]) => `[image: ${alt}] ${src}`), 'drawn as text');
|
|
const refusedText = await h.q('document.querySelector("#doc-visual .ProseMirror").textContent');
|
|
for (let i = 0; i < REFUSED.length; i++) assert.ok(refusedText.includes(`refused-${i}`), `the text around refused target ${i} is kept`);
|
|
for (const src of REFUSED) assert.ok(!refusedText.includes(src.trim()), `${src} is nowhere in the editor`);
|
|
const view = await h.inert();
|
|
assert.deepEqual([view.bad, view.handlers, view.hrefs, view.fetched, view.injected], [[], 0, 0, [], null]);
|
|
// a save: an image for each accepted target, written as markdown, and no refused target anywhere
|
|
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
|
|
await h.save();
|
|
const [{ content }] = await h.saved();
|
|
assert.deepEqual([...content.matchAll(/!\[([^\]]*)\]\(([^)\s]*)\)/g)].map(m => [m[1], m[2]]), ACCEPTED, 'saved images: the accepted targets, in order');
|
|
for (const src of REFUSED) assert.ok(!content.includes(src.trim()), `${src} is not in the saved file`);
|
|
assert.doesNotMatch(content, /javascript:|vbscript:|data:image|evil\.example/i);
|
|
for (let i = 0; i < REFUSED.length; i++) assert.ok(content.includes(`refused-${i}`), `the text around refused target ${i} is saved as text`);
|
|
await h.quiet();
|
|
});
|
|
});
|
|
|
|
// A switch of view reuses the draft it holds; everything else that opens a file still reads it, once each.
|
|
test('a switch of view reads nothing; a reload, and a trip away and back, each read the file exactly once', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
const settle = () => h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
|
|
const mix = () => h.reads.filter(p => p === 'docs/mix.md').length;
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
// a cold load renders twice (the page draws again when /api/config answers), so count from what opening cost
|
|
await settle();
|
|
const opened = mix();
|
|
assert.ok(opened >= 1, 'opening reads the file');
|
|
for (let i = 0; i < 2; i++) {
|
|
await h.q('document.querySelector("#doc-mode-source").click(), true');
|
|
await h.wait('document.querySelector("#doc-text")');
|
|
await h.q('document.querySelector("#doc-mode-visual").click(), true');
|
|
await h.pm();
|
|
}
|
|
await settle();
|
|
assert.equal(mix(), opened, 'two round trips between the views read nothing');
|
|
|
|
// Reload from the project: one read, and a switch after it reads nothing more
|
|
await h.q('document.querySelector("#doc-reload").click(), true');
|
|
await h.wait('document.querySelector("#doc-commit") && !document.querySelector("#bus-view [aria-busy]")');
|
|
await h.pm();
|
|
await settle();
|
|
assert.equal(mix(), opened + 1, 'Reload reads once');
|
|
await h.q('document.querySelector("#doc-mode-source").click(), true');
|
|
await h.wait('document.querySelector("#doc-text")');
|
|
await settle();
|
|
assert.equal(mix(), opened + 1, 'a switch after Reload reads nothing');
|
|
|
|
// away and back: the draft is clean, so opening the file again reads it once
|
|
await h.q('location.hash = "#/docs", true');
|
|
await h.wait('document.querySelector("#bus-view h1")?.textContent === "Docs" && !document.querySelector("#bus-view [aria-busy]")');
|
|
await h.open('docs/mix.md');
|
|
await settle();
|
|
assert.equal(mix(), opened + 2, 'going away and back reads once');
|
|
await h.quiet();
|
|
});
|
|
});
|
|
|
|
test('an edit rewrites only the block touched; a save sends the rest byte for byte; the Source view carries the draft',{ timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true, 'open with no edit: nothing to save');
|
|
// a table is drawn as a tinted source block, not a table
|
|
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror table").length'), 0);
|
|
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw").length'), 1);
|
|
// type at the end of the first paragraph
|
|
await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'First para.');
|
|
p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, p.firstChild.length); r.collapse(true); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`);
|
|
await h.b.call('Input.insertText', { text: ' More **not bold**.' });
|
|
await h.wait('!document.querySelector("#doc-save").disabled');
|
|
// Save is on because the draft differs, so the editor must hold the typed text, and keep holding it until the click.
|
|
const typed = () => h.q('document.querySelector("#doc-visual .ProseMirror").textContent');
|
|
assert.ok((await typed()).includes('First para. More **not bold**.'), 'the typed text is in the editor');
|
|
await h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
|
|
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), false, 'Save stays on');
|
|
assert.ok((await typed()).includes('First para. More **not bold**.'), 'the typed text is still in the editor');
|
|
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
|
|
await h.save();
|
|
const [sent] = await h.saved();
|
|
assert.deepEqual(Object.keys(sent).sort(), ['base', 'content', 'path', 'project'], 'the body row 148 sends');
|
|
assert.deepEqual([sent.project, sent.path, sent.base], ['stack', 'docs/mix.md', C1]);
|
|
// the typed `**` is text, so it is escaped; the table, the heading and the last paragraph are untouched
|
|
assert.equal(sent.content, '# Title\n\nFirst para. More \\*\\*not bold\\*\\*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n');
|
|
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), true, 'saved, so nothing left to save');
|
|
|
|
// the Source view shows the draft as the editor would save it, with no new read of the project, and an edit there
|
|
// comes back to the visual view
|
|
const readsBefore = h.reads.length;
|
|
await h.q('document.querySelector("#doc-mode-source").click(), true');
|
|
await h.wait('document.querySelector("#doc-text")');
|
|
assert.equal(await h.q('document.querySelector("#doc-text").value'), sent.content);
|
|
assert.equal(h.reads.length, readsBefore, 'a switch of view reads nothing');
|
|
await h.q(`(() => { const t = document.querySelector('#doc-text'); t.value = t.value.replace('Last para.', 'Final para.'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
|
|
await h.q('document.querySelector("#doc-mode-visual").click(), true');
|
|
await h.pm();
|
|
assert.ok((await h.q('document.querySelector("#doc-visual .ProseMirror").textContent')).includes('Final para.'));
|
|
assert.equal(await h.q('document.querySelector("#doc-save").disabled'), false);
|
|
assert.equal(h.reads.length, readsBefore, 'the switch back reads nothing either');
|
|
|
|
// toolbar and keys: bold with the key, a heading from the toolbar, a list from the markdown shortcut
|
|
await h.q(`(() => { const p = [...document.querySelectorAll('#doc-visual .ProseMirror p')].find(e => e.textContent === 'Final para.');
|
|
p.parentElement.focus(); const r = document.createRange(); r.setStart(p.firstChild, 0); r.setEnd(p.firstChild, 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return true; })()`);
|
|
await h.wait('getSelection().toString() === "Final"');
|
|
await h.q('document.querySelector(".ed-btn[data-cmd=strong]").click(), true');
|
|
await h.wait('document.querySelectorAll("#doc-visual .ProseMirror strong").length === 1');
|
|
await h.q('document.querySelector(".ed-btn[data-cmd=h2]").click(), true');
|
|
await h.wait('document.querySelectorAll("#doc-visual .ProseMirror h2").length === 1');
|
|
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: '3'.repeat(40), at: '2026-10-16T02:00:00Z' } });
|
|
await h.save();
|
|
const second = (await h.saved())[1];
|
|
assert.equal(second.content, '# Title\n\nFirst para. More \\*\\*not bold\\*\\*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n## **Final** para.\n');
|
|
assert.equal(second.base, C2, 'the second save is based on the commit the first one made');
|
|
await h.quiet();
|
|
});
|
|
});
|
|
|
|
test('a file the editor cannot write back byte for byte opens as Source, says so, cannot save, and the Visual button works once it can', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
const settle = () => h.q('new Promise(r => setTimeout(r, 300)).then(() => true)');
|
|
const shown = () => h.q(`({ pm: !!document.querySelector('#doc-visual .ProseMirror'), text: document.querySelector('#doc-text')?.value ?? null,
|
|
note: document.querySelector('#bus-view')?.textContent.includes('The visual editor is not available for this file, so it is open as source.'),
|
|
pressed: [document.querySelector('#doc-mode-visual')?.getAttribute('aria-pressed'), document.querySelector('#doc-mode-source')?.getAttribute('aria-pressed')],
|
|
saveOff: document.querySelector('#doc-save')?.disabled })`);
|
|
const reload = async () => { await h.q('document.querySelector("#doc-reload").click(), true'); await h.wait('document.querySelector("#doc-commit")?.textContent !== "" && !document.querySelector("#bus-view [aria-busy]")'); await settle(); };
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
assert.deepEqual((await shown()).pressed, ['true', 'false'], 'a plain file opens in the visual editor');
|
|
|
|
// roundTrip disagrees with the file: the gate refuses, whatever else the editor could do
|
|
await h.q(`(() => { const real = window.MosaicEditor; window.__rt = false; window.MosaicEditor = { mount: real.mount, roundTrip: t => window.__rt ? real.roundTrip(t) : t + ' ' }; return true; })()`);
|
|
await reload();
|
|
assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'a failed round trip shows the source, the note and a Save that is off');
|
|
// the screen shows Source, so the Visual button must act: once the file round-trips it opens the editor with no new read
|
|
await h.q('window.__rt = true, true');
|
|
const reads = h.reads.length;
|
|
await h.q('document.querySelector("#doc-mode-visual").click(), true');
|
|
await h.pm();
|
|
assert.deepEqual((await shown()).pressed, ['true', 'false'], 'Visual works from a fallback');
|
|
assert.equal(h.reads.length, reads, 'the switch reads nothing');
|
|
// the preference is not lost: the next fallback is a fallback again, not the Source the user never picked
|
|
await h.q('window.__rt = false, true');
|
|
await reload();
|
|
assert.equal((await shown()).pm, false);
|
|
|
|
// no editor script at all: the same page, the same note
|
|
await h.q('window.MosaicEditor = undefined, true');
|
|
await reload();
|
|
assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'without window.MosaicEditor the page is Source with the note');
|
|
await h.q('document.querySelector("#doc-mode-visual").click(), true');
|
|
await settle();
|
|
assert.deepEqual(await shown(), { pm: false, text: MIX, note: true, pressed: ['false', 'true'], saveOff: true }, 'Visual with no editor stays Source');
|
|
await h.quiet();
|
|
});
|
|
});
|
|
|
|
// Dewey's r1 finding: a link title, an image alt or title and a code block's info string are written into one line of
|
|
// Markdown, a paste can set any of them, and a line break in one saved a raw HTML block. Each of the four, on the paste path.
|
|
// (The editor's own reading of a file can give a single line break in a title or alt, never a blank line; see BUILD.md.)
|
|
const BREAK = 'T\n\n<img src=x onerror=alert(1)>\n\n';
|
|
// Each test first waits for the pasted node, holding the hostile value, to be in the editor's document (read from its DOM),
|
|
// so a paste that was dropped fails there and not as a missing string after Save. The <pre> comes between two paragraphs:
|
|
// prosemirror-view opens the first and last node of a pasted slice (Slice.maxOpen), so a lone <pre> joins the block the
|
|
// cursor is in as plain text and sets no params; the paste here lands in the first block, the heading.
|
|
const attrOf = v => v.replace(/&/g, '&').replace(/"/g, '"');
|
|
// One paste, then Save: `value` is what the pasted node must hold in the document, `written` what the file must then contain.
|
|
// With `reread`, the saved text is also opened by the editor's own reader (a second, read-only mount): the title must come
|
|
// back as pasted and no tag may have come out as text of the document, which is what a renderer with raw HTML would run.
|
|
function pasteTest(name, html, value, written, node, attr, headline = `${name} pasted with line breaks in it is written on one line, so no HTML block comes of it`, reread = false) {
|
|
test(headline, { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
await h.open('docs/mix.md');
|
|
await h.pm();
|
|
await h.q(`(() => {
|
|
const p = document.querySelector('#doc-visual .ProseMirror'); p.focus();
|
|
getSelection().selectAllChildren(p); getSelection().collapseToEnd();
|
|
const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(html)});
|
|
p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
return true; })()`);
|
|
const sel = `#doc-visual .ProseMirror ${node}[${attr}]`;
|
|
await h.wait(`document.querySelector(${JSON.stringify(sel)})`);
|
|
assert.equal(await h.q(`document.querySelector(${JSON.stringify(sel)}).getAttribute(${JSON.stringify(attr)})`), value, 'the pasted node holds the hostile value in the document');
|
|
await h.wait('!document.querySelector("#doc-save").disabled');
|
|
await h.answer({ status: 200, body: { path: 'docs/mix.md', commit: C2, at: '2026-10-16T01:00:00Z' } });
|
|
await h.save();
|
|
const [{ content }] = await h.saved();
|
|
assert.ok(content.includes(written), `the value is written as ${JSON.stringify(written)} in ${JSON.stringify(content)}`);
|
|
assert.doesNotMatch(content, /^[ \t]*<img/m, 'no line of the file starts an HTML block');
|
|
assert.doesNotMatch(content, /\n[ \t]*\n[ \t]*<img/, 'no blank line then a tag');
|
|
if (reread) {
|
|
const back = await h.q(`(() => {
|
|
const host = document.createElement('div'); document.body.appendChild(host);
|
|
const ed = window.MosaicEditor.mount(host, { text: ${JSON.stringify(content)}, editable: false });
|
|
const titles = [];
|
|
ed.view.state.doc.descendants(n => { if (n.type.name === 'image') titles.push(n.attrs.title); for (const m of n.marks) if (m.type.name === 'link') titles.push(m.attrs.title); });
|
|
const text = ed.view.state.doc.textContent;
|
|
ed.destroy(); host.remove();
|
|
return { titles, text }; })()`);
|
|
assert.deepEqual(back.titles, [value], 'the saved title reads back as it was pasted');
|
|
assert.ok(!back.text.includes('<img'), `no tag in the text of the re-read document: ${JSON.stringify(back.text)}`);
|
|
}
|
|
await h.quiet();
|
|
});
|
|
});
|
|
}
|
|
for (const [name, html, written, node, attr] of [
|
|
['a link title', `<p><a href="https://ok.example/p" title="${BREAK}">word</a></p>`, '[word](https://ok.example/p "T <img src=x onerror=alert(1)> ")', 'a[href="https://ok.example/p"]', 'title'],
|
|
['an image alt', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="${BREAK}">x</span></p>`, '', 'span.md-image', 'data-alt'],
|
|
['an image title', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="a" data-title="${BREAK}">x</span></p>`, '> ")', 'span.md-image', 'data-title'],
|
|
['a code block info string', `<p>x</p><pre data-params="${BREAK}">code</pre><p>y</p>`, '```T\ncode\n```', 'pre[data-params]', 'data-params'],
|
|
]) pasteTest(name, html, BREAK, written, node, attr);
|
|
|
|
// Dewey's r2 finding: the title writers escaped `"` and not `\`. A title `x\") <img ...>` then ended its quotes early
|
|
// (the saved line re-read as a link titled `x\` followed by live inline HTML), and a title ending in `\` swallowed its own
|
|
// closing quote. Both shapes, for a link title and an image title, on the paste path. Written, each backslash is doubled
|
|
// and each quote gets its own backslash.
|
|
const ESCAPE = 'x\\") <img src=x onerror=alert(1)> ';
|
|
const TRAILING = 'end\\';
|
|
for (const [what, value, written] of [['a quote after a backslash then a tag', ESCAPE, 'x\\\\\\") <img src=x onerror=alert(1)> '], ['a trailing backslash', TRAILING, 'end\\\\']]) {
|
|
pasteTest('a link title', `<p><a href="https://ok.example/p" title="${attrOf(value)}">word</a></p>`, value, `[word](https://ok.example/p "${written}")`, 'a[href="https://ok.example/p"]', 'title',
|
|
`a link title holding ${what} is written with the backslash escaped, so the title stays closed`, true);
|
|
pasteTest('an image title', `<p><span class="md-image" data-src="https://ok.example/a.png" data-alt="a" data-title="${attrOf(value)}">x</span></p>`, value, ``, 'span.md-image', 'data-title',
|
|
`an image title holding ${what} is written with the backslash escaped, so the title stays closed`, true);
|
|
}
|
|
|
|
// Filbert's g5: a backtick in a code block's info string ends the fence if written; the load side refuses it first (tilde
|
|
// fence, below), so only a paste reaches the save side.
|
|
pasteTest('a code block info string', '<p>x</p><pre data-params="a`b">code</pre><p>y</p>', 'a`b', '```\ncode\n```', 'pre[data-params]', 'data-params',
|
|
'a code block info string with a backtick, pasted, is dropped on Save, so the fence stays closed');
|
|
|
|
test('a code block info string with a backtick, as a tilde fence can have, is kept as source, not rewritten without it', { timeout: 120000 }, async () => {
|
|
await session(async h => {
|
|
await h.open('docs/tilde.md');
|
|
await h.pm();
|
|
assert.equal(await h.q('document.querySelectorAll("#doc-visual .ProseMirror pre.md-raw").length'), 1, 'the block is a source block');
|
|
assert.ok((await h.q('document.querySelector("#doc-visual .ProseMirror pre.md-raw").textContent')).startsWith('~~~a`b'));
|
|
await h.quiet();
|
|
});
|
|
});
|