Files
AriWeb-Suite/modules/webmaker/test/visual-editor.test.js

818 lines
96 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// v1.8.0 직접 편집(시각 편집기) 단위 검증 — 서버 순수 모듈 lib/visual-editor.js 와 브라우저·Node 공용 핵심 public/visual-editor-core.js.
// 실행: npm test (node --test). 브라우저 없이 문서 변환·저장 검증·실행 취소/다시 실행·메시지 규약·구성 요소 목록을 검사한다.
const test = require('node:test'); const assert = require('node:assert/strict');
const path = require('path'); const vm = require('vm'); const fs = require('fs');
const ROOT = path.join(__dirname, '..');
const ve = require(ROOT + '/lib/visual-editor');
const core = require(ROOT + '/public/visual-editor-core');
/** 시험용 페이지: 문서형·머리의 스타일/스크립트·본문의 인라인 핸들러·javascript: 링크·iframe·본문 끝 스크립트를 모두 가진다 */
const PAGE = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>T</title>
<link rel="stylesheet" href="style.css">
<style>h1{color:red}</style>
<script src="/wm-stats.js"></script>
<script>window.boot = 1 // <b>not html</b></script>
</head>
<body class="home">
<header><h1 id="t">안녕</h1><a href="javascript:alert(1)" onclick="go()">링크</a></header>
<main><p>본문</p><img src="images/a.png" alt="a"><iframe src="https://example.com/x"></iframe></main>
<script src="/wm-modules.js"></script>
</body>
</html>
`;
const OPTS = { baseHref: 'http://h/abc/', session: 'sess-1234', nonce: 'nonce-5678', parentOrigin: 'http://h', runtime: 'console.log("rt")' };
test('splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다', () => {
const s = ve.splitDocument(PAGE);
assert.equal(s.hasBody, true);
assert.ok(s.prefix.endsWith('<body class="home">'), s.prefix.slice(-40));
assert.ok(s.prefix.includes('<style>h1{color:red}</style>'));
assert.ok(s.body.startsWith('\n<header>') && s.body.includes('<script src="/wm-modules.js"></script>'));
assert.ok(s.suffix.startsWith('</body>') && s.suffix.trimEnd().endsWith('</html>'));
assert.equal(s.prefix + s.body + s.suffix, PAGE, '세 조각을 이으면 원문');
const frag = ve.splitDocument('<p>조각</p>'); assert.equal(frag.hasBody, false); assert.equal(frag.body, '<p>조각</p>');
});
test('prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다', () => {
const html = ve.prepareCanvas(PAGE, OPTS);
assert.match(html, /<meta http-equiv="Content-Security-Policy" content="[^"]*script-src 'nonce-nonce-5678'/);
assert.ok(html.includes('<base href="http://h/abc/"'), 'base 주입');
// 원래 스크립트는 모두 실행되지 않는 종류로 바뀌고(외부 src 포함), 내용은 그대로 남는다
const scripts = [...html.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]);
const original = scripts.filter((s) => !/nonce="nonce-5678"/.test(s));
assert.equal(original.length, 3, JSON.stringify(scripts));
for (const s of original) assert.match(s, /type="text\/x-wm-ve-disabled"/);
assert.ok(html.includes('window.boot = 1 // <b>not html</b>'), '스크립트 내용 보존');
assert.ok(!/\sonclick=/.test(html) && html.includes('data-wm-ve-on-click="go()"'), '인라인 핸들러는 보존 속성으로');
assert.ok(!/\shref="javascript:/.test(html) && html.includes('data-wm-ve-href="javascript:alert(1)"'), 'javascript: 링크 비활성화');
assert.ok(/<iframe data-wm-ve-src="https:\/\/example\.com\/x">/.test(html), 'iframe 은 불러오지 않는다');
assert.ok(html.includes('<title>T</title>') && html.includes('<style>h1{color:red}</style>') && html.includes('<body class="home">'), '머리·스타일·본문 태그 보존');
// 런타임은 nonce 를 가진 편집기 전용 스크립트로, 설정(세션·부모 출처)을 함께 넣는다
assert.ok(html.includes('"session":"sess-1234"') && html.includes('"parentOrigin":"http://h"'));
assert.ok(/<script nonce="nonce-5678" data-wm-ve-ui>console\.log\("rt"\)<\/script>/.test(html));
// 런타임 안의 </script> 는 문서를 끊지 못한다
const h2 = ve.prepareCanvas(PAGE, { ...OPTS, runtime: 'var s = "</script><img src=x onerror=alert(1)>";' });
assert.ok(!h2.includes('</script><img src=x onerror'), '런타임 문자열의 </script> 는 이스케이프');
});
test('restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다', () => {
const body = '<header data-wm-ve-id="3"><h1 id="t" contenteditable="true" data-wm-ve-editable="" class="wm-ve-selected big" spellcheck="false" data-wm-ve-spellcheck="">안녕하세요</h1>'
+ '<div data-wm-ve-ui="" class="wm-ve-toolbar"><button onclick="x()">삭제</button><script>evil()</script></div>'
+ '<a data-wm-ve-href="javascript:alert(1)" data-wm-ve-on-click="go()">링크</a></header>'
+ '<main><iframe data-wm-ve-src="https://example.com/x"></iframe><script type="text/x-wm-ve-disabled">a()</script><script type="text/x-wm-ve-disabled" data-wm-ve-orig-type="module">b()</script></main>';
const out = ve.restoreFromCanvas(body);
assert.equal(out, '<header><h1 id="t" class="big">안녕하세요</h1><a href="javascript:alert(1)" onclick="go()">링크</a></header>'
+ '<main><iframe src="https://example.com/x"></iframe><script>a()</script><script type="module">b()</script></main>');
});
test('validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다', () => {
const canvasBody = ve.splitDocument(ve.prepareCanvas(PAGE, OPTS)).body.replace('안녕', '안녕하세요');
const r = ve.validateVisualSave({ original: PAGE, body: canvasBody, maxBytes: 1024 * 1024 });
const s0 = ve.splitDocument(PAGE); const s1 = ve.splitDocument(r.html);
assert.equal(s1.prefix, s0.prefix, '머리 보존'); assert.equal(s1.suffix, s0.suffix, '꼬리 보존');
assert.ok(s1.body.includes('<h1 id="t">안녕하세요</h1>'));
assert.ok(s1.body.includes('<a href="javascript:alert(1)" onclick="go()">링크</a>'), '원래 있던 핸들러·링크는 보존');
assert.ok(s1.body.includes('<script src="/wm-modules.js"></script>'), '본문 끝 스크립트 보존');
assert.ok(!/data-wm-ve|x-wm-ve-disabled|contenteditable/.test(r.html), '편집기 표식 없음');
assert.equal(typeof r.hash, 'string');
});
test('validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다', () => {
const base = ve.splitDocument(PAGE).body;
const reject = (body, re, status = 400, opts = {}) => { let err = null; try { ve.validateVisualSave({ original: PAGE, body, maxBytes: 1024 * 1024, ...opts }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + re); assert.equal(err.status, status, err.message); assert.match(err.message, re); };
reject(base + '<script>alert(1)</script>', /스크립트/);
reject(base.replace('<script src="/wm-modules.js">', '<script src="/evil.js">'), /스크립트/);
reject(base.replace('<p>본문</p>', '<p onmouseover="steal()">본문</p>'), /이벤트 속성/);
reject(base.replace('<p>본문</p>', '<p>본문 <a href="javascript:void(0)">x</a></p>'), /허용되지 않는 (링크|주소)/);
reject(base.replace('<p>본문</p>', '<a href="data:text/html,<b>x</b>">x</a>'), /허용되지 않는 (링크|주소)/);
reject(base.replace('<p>본문</p>', '<p>본문</p><iframe src="https://evil.example/"></iframe>'), /iframe/);
reject(base.replace('<p>본문</p>', '<object data="x.swf"></object>'), /object/);
reject(base.replace('<p>본문</p>', '<p style="width:expression(alert(1))">본문</p>'), /스타일/);
reject(base.replace('<p>본문</p>', '<body><p>본문</p></body>'), /html·head·body/);
reject(base, /너무 큽니다/, 413, { maxBytes: 10 });
reject(42, /문자열/);
reject(base + '<script type="text/x-wm-ve-disabled" data-wm-ve-ui>x</script><script>fresh()</script>', /스크립트/);
});
test('validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절', () => {
const base = ve.splitDocument(PAGE).body;
const r = ve.validateVisualSave({ original: PAGE, body: base.replace('<script src="/wm-modules.js"></script>', '').replace('<iframe src="https://example.com/x"></iframe>', ''), maxBytes: 1e6 });
assert.ok(!r.html.includes('/wm-modules.js') && !r.html.includes('<iframe'));
assert.throws(() => ve.validateVisualSave({ original: PAGE, body: base + '<script src="/wm-modules.js"></script>', maxBytes: 1e6 }), /스크립트/);
});
test('validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다', () => {
assert.throws(() => ve.validateVisualSave({ original: '<html><head><title>x</title></head></html>', body: '<p>a</p>', maxBytes: 1e6 }), /body/);
const r = ve.validateVisualSave({ original: '<p>조각</p>', body: '<p>조각 수정</p>', maxBytes: 1e6 }); assert.equal(r.html, '<p>조각 수정</p>');
});
test('isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티(&colon; &Tab; &NewLine; &sol;)·숫자 엔티티(10/16진, 대소문자, 세미콜론 없음)·제어 문자·대소문자 섞임으로 감춘 javascript:/vbscript:/data:text 주소를 모든 주소 속성에서 거절하고, 모르는 엔티티로 흐린 스킴도 거절한다', () => {
const bads = ['jav&#x61;script&colon;alert(1)', 'javascript&colon;alert(1)', 'JaVa&Tab;ScRiPt:alert(1)', '&NewLine;javascript:alert(1)', 'java&NewLine;script&colon;alert(1)', '&#x6a;avascript:alert(1)', '&#106;avascript:alert(1)', '&#X6A;&#x61;vascript&colon;alert(1)', 'j&#97va&#115cript&#58alert(1)',
'java\u0000script:alert(1)', ' javascript:alert(1)', 'javascript\t:alert(1)', 'java​script:alert(1)', 'java&bogus;script:alert(1)', 'vbscript&colon;x', 'data&colon;text/html,<b>x</b>', 'data:text&sol;html,x', 'DATA:TEXT/HTML;base64,PGI+', '&#x6A;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;:1', '&#0000106;avascript:1', 'javascript&#x3a;alert(1)'];
for (const b of bads) assert.equal(ve.isDangerousUrl(b), true, '거절: ' + JSON.stringify(b));
const goods = ['https://a/b?x=1&amp;y=2', 'about.html?x&colon;y', 'mailto:a@b.c', 'tel:+82-10', '#top', 'data:image/png;base64,AAAA', '/path/with&amp;x', 'images/a.png', '//cdn.example/x.js', 'https://e/?q=javascript:x'];
for (const g of goods) assert.equal(ve.isDangerousUrl(g), false, '허용: ' + JSON.stringify(g));
// 서버 변환: 감춘 주소도 캔버스에서 보존 속성으로 바뀐다
const canvas = ve.prepareCanvas('<body><a href="javascript&colon;alert(1)">x</a><img src="&#x6a;avascript:alert(1)"></body>', OPTS);
assert.ok(!/\shref="javascript&colon;/.test(canvas) && canvas.includes('data-wm-ve-href="javascript&colon;alert(1)"'), canvas); assert.ok(canvas.includes('data-wm-ve-src="&#x6a;avascript:alert(1)"'));
// 저장 검증: 어떤 주소 속성이든 거절
const base = ve.splitDocument(PAGE).body;
for (const [tag, attr] of [['a', 'href'], ['img', 'src'], ['form', 'action'], ['button', 'formaction'], ['video', 'poster'], ['use', 'xlink:href'], ['a', 'ping'], ['img', 'srcset'], ['blockquote', 'cite'], ['img', 'longdesc'], ['body', 'background']]) {
for (const b of ['javascript&colon;alert(1)', '&#x6a;avascript:alert(1)', 'JaVa&Tab;ScRiPt:alert(1)']) {
const body = base.replace('<p>본문</p>', `<${tag} ${attr}="${b}">x</${tag}>`);
let err = null; try { ve.validateVisualSave({ original: PAGE, body, maxBytes: 1e6 }); } catch (e) { err = e; }
assert.ok(err && err.status === 400, `${tag}[${attr}]=${b} 거절`); assert.match(err.message, tag === 'body' ? /html·head·body|허용되지 않는/ : /허용되지 않는/);
}
}
});
test('prepareCanvas: 주석·원문(script/style/title) 안의 <head> 흉내·<header> 는 무시하고 진짜 <head> 뒤에만 CSP·런타임을 한 번 넣으며, <head> 가 없으면 진짜 <head> 를 만든다', () => {
const page = '<!doctype html><!--<head>--><html lang="ko"><script>var s = "<head>"</script><style>/*<head>*/</style><title><head></title><head><meta charset="utf-8"></head><body><header><h1>x</h1><script>evil()</script></header></body></html>';
const out = ve.prepareCanvas(page, OPTS);
assert.equal((out.match(/Content-Security-Policy/g) || []).length, 1, 'CSP 한 번'); assert.equal((out.match(/console\.log\("rt"\)/g) || []).length, 1, '런타임 한 번');
assert.ok(out.includes('<!--<head>-->'), '주석은 그대로'); assert.ok(out.includes('var s = "<head>"'), '스크립트 원문 그대로'); assert.ok(out.includes('<title><head></title>'), 'title 원문 그대로');
assert.ok(out.includes('<head><meta http-equiv="Content-Security-Policy"'), '진짜 <head> 바로 뒤에'); assert.ok(out.indexOf('Content-Security-Policy') > out.indexOf('</title>'), 'title 뒤의 진짜 head');
assert.ok(out.includes('<script type="text/x-wm-ve-disabled">evil()</script>'), '원래 스크립트는 비활성화된 채로');
// <head> 가 없고 <header> 만 있는 문서: <html> 바로 뒤에 진짜 <head> 를 만들고 <header> 는 건드리지 않는다
const out2 = ve.prepareCanvas('<html><body><header><h1>x</h1></header></body></html>', OPTS);
assert.ok(out2.startsWith('<html><head><meta http-equiv="Content-Security-Policy"'), out2.slice(0, 80)); assert.ok(out2.includes('</head><body><header><h1>x</h1></header>'), '<header> 안에 넣지 않는다'); assert.equal((out2.match(/Content-Security-Policy/g) || []).length, 1);
// <html> 도 없고 <body> 만 있으면 <body> 앞에 머리를 만든다 · 아무 구조도 없는 조각은 문서로 감싼다 · 주석 속 <head> 만 있는 조각도 진짜 머리를 얻는다
const out3 = ve.prepareCanvas('<!doctype html><body class="x"><p>a</p></body>', OPTS); assert.ok(out3.startsWith('<!doctype html><head><meta http-equiv="Content-Security-Policy"') && out3.includes('</head><body class="x"><p>a</p></body>'), out3.slice(0, 120));
const out4 = ve.prepareCanvas('<!--<head>--><p>frag</p>', OPTS); assert.ok(out4.startsWith('<!doctype html><html><head><meta http-equiv="Content-Security-Policy"') && out4.includes('<body><!--<head>--><p>frag</p></body>'), out4.slice(0, 160));
const out5 = ve.prepareCanvas('<html><!-- <head> --><body><p>a</p></body></html>', OPTS); assert.ok(out5.startsWith('<html><head><meta http-equiv="Content-Security-Policy"') && out5.includes('</head><!-- <head> --><body>'), out5.slice(0, 160));
});
test('validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 스크립트·핸들러·위험 주소·iframe/object 를 본문으로 복사해 오면 거절한다(완성 문서 전체를 원본 전체와 비교) — 본문 끝으로 옮겨진 꼬리 스크립트는 허용', () => {
const orig = '<!doctype html><html><head><script src="/head.js"></script><link rel="x" href="javascript:h()"><object data="x.swf"></object><iframe src="https://e/"></iframe></head><body onload="init()"><p>본문</p></body></html>';
const reject = (body, re) => { let err = null; try { ve.validateVisualSave({ original: orig, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body); assert.equal(err.status, 400); assert.match(err.message, re); };
reject('<p>본문</p><script src="/head.js"></script>', /스크립트/);
reject('<p onload="init()">본문</p>', /이벤트 속성/);
reject('<p>본문</p><a href="javascript:h()">x</a>', /허용되지 않는/);
reject('<p>본문</p><iframe src="https://e/"></iframe>', /iframe/);
reject('<p>본문</p><object data="x.swf"></object>', /object/);
const ok = ve.validateVisualSave({ original: orig, body: '<p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(ok.html.includes('<body onload="init()"><p>본문 수정</p></body>'));
// 브라우저가 </body> 뒤의 스크립트를 본문 끝으로 옮겨 돌려준 경우: 중복 없이 한 번만 남고 허용
const orig2 = '<html><head></head><body><p>a</p></body><script src="/tail.js"></script></html>';
const r2 = ve.validateVisualSave({ original: orig2, body: '<p>a 수정</p><script src="/tail.js"></script>', maxBytes: 1e6 });
assert.equal((r2.html.match(/tail\.js/g) || []).length, 1, r2.html); assert.ok(r2.html.includes('<p>a 수정</p>'));
assert.throws(() => ve.validateVisualSave({ original: orig2, body: '<p>a</p><script src="/tail.js"></script><script src="/tail.js"></script>', maxBytes: 1e6 }), /스크립트/, '두 번 복사는 거절');
});
test('validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(animate·set·animateMotion·animateTransform·animateColor·discard·mpath — 대소문자·접두어 포함)는 정적 주소 검사를 지나 href 를 javascript: 로 바꿀 수 있으므로 새로 넣거나 바꾸면 거절하고, 원래 있던 것은 그 수까지만(삭제 허용), 캔버스에서는 실행되지 않는 이름으로 바꿨다가 복원 때 되돌린다', () => {
const base = ve.splitDocument(PAGE).body;
const reject = (body, re, original = PAGE) => { let err = null; try { ve.validateVisualSave({ original, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body.slice(0, 120)); assert.equal(err.status, 400, err.message); assert.match(err.message, re); };
// 1) 저장형 XSS 우회: 정적 href 는 "#x" 라 주소 검사를 지나지만 animate/set 이 뒤에 href 를 javascript: 로 바꾼다(Chromium 에서 실행됨)
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text></a><animate attributeName="href" values="javascript:alert(1)" begin="0s" xlink:href="#a"/></svg>'), /animate/);
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text><set attributeName="xlink:href" to="javascript:alert(1)"/></a></svg>'), /set/);
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text><SET attributeName="href" to="data:text/html,<b>x</b>"></SET></a></svg>'), /set/);
// 2) 값에 위험 주소가 없어도 SMIL 요소 자체를 새로 넣을 수 없다(fail-closed) — 대소문자·이름 공간 접두어 형태 모두
for (const tag of ['animate', 'set', 'animateMotion', 'animateTransform', 'animateColor', 'discard', 'mpath', 'ANIMATE', 'AnimateMotion', 'svg:animate', 'SVG:Set', 'svg:mpath']) {
reject(base.replace('<p>본문</p>', `<svg><rect width="1" height="1"/><${tag} attributeName="x" to="1" dur="1s"/></svg>`), new RegExp(tag.replace(/^[^:]+:/, '').toLowerCase(), 'i'));
reject(base.replace('<p>본문</p>', `<svg><rect width="1" height="1"/><${tag} attributeName="x" to="1" dur="1s"></${tag}></svg>`), new RegExp(tag.replace(/^[^:]+:/, '').toLowerCase(), 'i'));
}
// 3) 원래 있던 SMIL: 그대로 두거나 지우는 것은 허용, 속성을 바꾸거나 하나 더 넣으면 거절(기존 목록 비교 규칙)
const orig = '<!doctype html><html><head><title>s</title></head><body><svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문</p></body></html>';
const same = ve.validateVisualSave({ original: orig, body: '<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(same.html.includes('<animate attributeName="r" to="5" dur="1s"/>') && same.html.includes('본문 수정'));
const gone = ve.validateVisualSave({ original: orig, body: '<svg><circle r="1"></circle></svg><p>본문</p>', maxBytes: 1e6 }); assert.ok(!/<animate/.test(gone.html), '삭제 허용');
reject('<svg><circle r="1"><animate attributeName="href" to="javascript:alert(1)" dur="1s"/></circle></svg><p>본문</p>', /animate/, orig);
reject('<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문</p>', /animate/, orig);
reject('<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle><a href="#q"><set attributeName="href" to="javascript:x()"/></a></svg><p>본문</p>', /set/, orig);
// 4) 캔버스: SMIL 요소는 실행되지 않는 이름(wm-ve-x-…)으로 바뀌고(여는·닫는 태그 모두), 복원 때 원래 이름으로 돌아와 그대로 저장된다
const page2 = '<html><head></head><body><svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/><animateMotion dur="1s"><mpath xlink:href="#p"/></animateMotion><set attributeName="x" to="1"></set></circle></svg><p>본문</p></body></html>';
const canvas = ve.prepareCanvas(page2, OPTS);
assert.ok(!/<\/?(animate|animateMotion|mpath|set)[\s>\/]/i.test(canvas), 'SMIL 요소 이름이 캔버스에 남지 않는다: ' + canvas.slice(canvas.indexOf('<svg'), canvas.indexOf('</svg>')));
assert.ok(canvas.includes('<wm-ve-x-animate attributeName="r" to="5" dur="1s" />') && canvas.includes('<wm-ve-x-animateMotion dur="1s"><wm-ve-x-mpath xlink:href="#p" /></wm-ve-x-animateMotion>') && canvas.includes('<wm-ve-x-set attributeName="x" to="1"></wm-ve-x-set>'), canvas);
// 브라우저는 알 수 없는 SVG 요소 이름을 소문자로 직렬화한다 — 복원은 표준 이름(animateMotion 등)으로 돌려준다
const fromBrowser = '<svg><circle r="1"><wm-ve-x-animate attributeName="r" to="5" dur="1s"></wm-ve-x-animate><wm-ve-x-animatemotion dur="1s"><wm-ve-x-mpath xlink:href="#p"></wm-ve-x-mpath></wm-ve-x-animatemotion><wm-ve-x-set attributeName="x" to="1"></wm-ve-x-set></circle></svg><p>본문 수정</p>';
const restored = ve.restoreFromCanvas(fromBrowser);
assert.equal(restored, '<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"></animate><animateMotion dur="1s"><mpath xlink:href="#p"></mpath></animateMotion><set attributeName="x" to="1"></set></circle></svg><p>본문 수정</p>');
const saved = ve.validateVisualSave({ original: page2, body: fromBrowser, maxBytes: 1e6 }); assert.ok(saved.html.includes('<animateMotion dur="1s"><mpath xlink:href="#p"></mpath></animateMotion>') && saved.html.includes('본문 수정'), '원래 있던 SMIL 은 그대로 저장');
assert.throws(() => ve.validateVisualSave({ original: page2, body: fromBrowser + '<svg><wm-ve-x-animate attributeName="href" to="javascript:x()"></wm-ve-x-animate></svg>', maxBytes: 1e6 }), /animate/, '캔버스 이름으로 몰래 넣어도 복원 뒤 거절');
// 5) 구성 요소 HTML 검사(core)도 SMIL 요소를 거절한다
for (const h of ['<svg><a href="#x"><animate attributeName="href" values="javascript:alert(1)"/></a></svg>', '<svg><set attributeName="href" to="#"/></svg>', '<svg><animateMotion/></svg>', '<svg><ANIMATETRANSFORM attributeName="transform"/></svg>', '<svg><svg:animate/></svg>', '<svg><mpath/></svg>', '<svg><discard/></svg>']) assert.equal(core.isSafeComponentHtml(h), false, '구성 요소 거절: ' + h);
assert.equal(core.isSafeComponentHtml('<svg viewBox="0 0 1 1"><rect width="1" height="1"/><text>set</text></svg><p>animate</p>'), true, '글자 속 단어는 요소가 아니다');
});
test('scanTags 파서 차이(저장형 XSS): HTML 의 <title>·<textarea>·<style> 은 원문(RCDATA/raw text)이지만 <svg>·<math> 안(외래 콘텐츠)에서는 자식이 요소로 해석되어 <svg><title><script>…</script></title></svg> 의 스크립트가 실행된다 — 스캐너는 외래 콘텐츠 안에서 원문 규칙을 쓰지 않고(통합 지점 foreignObject·desc·mtext 포함, fail-closed) 그 스크립트를 세어 거절하며 캔버스에서는 비활성화한다; HTML 수준의 <title> 안 글자는 그대로 글자다', () => {
const base = ve.splitDocument(PAGE).body;
const reject = (body, re, original = PAGE) => { let err = null; try { ve.validateVisualSave({ original, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body.slice(0, 120)); assert.equal(err.status, 400, err.message); assert.match(err.message, re); };
const payloads = [
'<svg><title><script>alert(1)</script></title></svg>', '<svg><title><SCRIPT>alert(1)</SCRIPT></title></svg>', '<SVG><TITLE><script>alert(1)</script></TITLE></SVG>',
'<svg><desc><script>alert(1)</script></desc></svg>', '<svg><textarea><script>alert(1)</script></textarea></svg>', '<svg><style><script>alert(1)</script></style></svg>',
'<svg><g><title><script>alert(1)</script></title></g></svg>', '<svg><foreignObject><title><script>alert(1)</script></title></foreignObject></svg>', '<svg><svg><title><script>alert(1)</script></title></svg></svg>',
'<math><mtext><title><script>alert(1)</script></title></mtext></math>', '<math><annotation-xml encoding="text/html"><title><script>alert(1)</script></title></annotation-xml></math>', '<MATH><mi><textarea><script>alert(1)</script></textarea></mi></MATH>',
'<svg><title><img src=x onerror="alert(1)"></title></svg>', '<svg><title><a href="javascript:alert(1)">x</a></title></svg>', '<svg><title><iframe src="https://e/"></iframe></title></svg>',
];
for (const p of payloads) reject(base.replace('<p>본문</p>', p), /스크립트|이벤트 속성|허용되지 않는|iframe/);
// 외래 콘텐츠 안에서 원문처럼 보이는 요소가 "닫히지 않은" 채로 와도(스캐너가 끝까지 원문으로 먹으면 뒤의 스크립트가 숨는다) 거절
reject(base.replace('<p>본문</p>', '<svg><title>x</title><title><script>alert(1)</script></svg>'), /스크립트/);
// HTML 수준: <title>·<textarea> 안의 <script> 글자는 글자다(기존 구조 테스트와 같은 규칙) — 본문에 textarea 로 넣으면 허용, 머리의 <title> 도 원문 그대로
const okBody = ve.validateVisualSave({ original: PAGE, body: base.replace('<p>본문</p>', '<p>본문</p><textarea>&lt;script&gt; 와 <script>alert(1)</script> 는 글자</textarea>'), maxBytes: 1e6 }); assert.ok(okBody.html.includes('<textarea>&lt;script&gt; 와 <script>alert(1)</script> 는 글자</textarea>'));
const orig2 = '<!doctype html><html><head><title>T <script>not code</script></title></head><body><p>본문</p></body></html>';
const ok2 = ve.validateVisualSave({ original: orig2, body: '<p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(ok2.html.includes('<title>T <script>not code</script></title>') && ok2.html.includes('본문 수정'));
// 정상 SVG(제목·설명·스타일 글자)는 그대로 허용되고, 닫힌 <svg> 뒤의 HTML <title>/<textarea> 는 다시 원문이다
const ok3 = ve.validateVisualSave({ original: PAGE, body: base.replace('<p>본문</p>', '<svg viewBox="0 0 1 1"><title>차트</title><desc>설명</desc><style>.a{fill:red}</style><rect class="a" width="1" height="1"/></svg><p>본문</p><textarea><b>x</b></textarea>'), maxBytes: 1e6 }); assert.ok(ok3.html.includes('<title>차트</title>') && ok3.html.includes('<textarea><b>x</b></textarea>'));
// 캔버스 출력: svg 안 title 의 script 는 요소로 보고 비활성화한다(실행 안 됨), HTML title 안 글자는 그대로
const canvas = ve.prepareCanvas('<html><head><title>A <script>s</script></title></head><body><svg><title><script>evil()</script></title></svg></body></html>', OPTS);
assert.ok(canvas.includes('<title>A <script>s</script></title>'), 'HTML title 원문 그대로'); assert.ok(canvas.includes('<svg><title><script type="text/x-wm-ve-disabled">evil()</script></title></svg>'), 'svg 안 script 비활성화: ' + canvas.slice(canvas.indexOf('<svg'), canvas.indexOf('</svg>') + 6));
});
test('contentHash·isHtmlPath', () => {
assert.equal(ve.contentHash('abc'), ve.contentHash('abc')); assert.notEqual(ve.contentHash('abc'), ve.contentHash('abd')); assert.equal(ve.contentHash('x').length, 64);
assert.equal(ve.isHtmlPath('index.html'), true); assert.equal(ve.isHtmlPath('en/about.htm'), true); assert.equal(ve.isHtmlPath('style.css'), false); assert.equal(ve.isHtmlPath('a.html/x.js'), false);
});
test('writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다', () => {
const os = require('os'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-atomic-')); const f = path.join(dir, 'index.html'); fs.writeFileSync(f, 'old');
ve.writeTextAtomic(f, 'new 내용');
assert.equal(fs.readFileSync(f, 'utf8'), 'new 내용'); assert.equal(fs.statSync(f).mode & 0o777, 0o644); assert.deepEqual(fs.readdirSync(dir), ['index.html'], '임시 파일 잔여 없음');
// 주입한 파일 시스템: rename 이 실패하면 임시 파일을 지우고 오류를 그대로 던진다 — 원본 경로에는 쓰지 않는다
const calls = [];
const failRename = { writeFileSync: (p, c, o) => calls.push(['write', p, o && o.mode]), renameSync: () => { throw Object.assign(new Error('ENOSPC: 공간 없음'), { code: 'ENOSPC' }); }, unlinkSync: (p) => calls.push(['unlink', p]) };
assert.throws(() => ve.writeTextAtomic(f, 'x', { fs: failRename }), /ENOSPC/);
const w = calls.find((c) => c[0] === 'write'); assert.ok(w, '임시 파일에 썼다'); assert.notEqual(w[1], f, '원본 경로에 직접 쓰지 않는다'); assert.equal(path.dirname(w[1]), dir, '같은 폴더(원자적 rename 가능)'); assert.match(path.basename(w[1]), /\.tmp$/); assert.equal(w[2], 0o644);
assert.deepEqual(calls.filter((c) => c[0] === 'unlink').map((c) => c[1]), [w[1]], '실패한 임시 파일 정리');
assert.equal(fs.readFileSync(f, 'utf8'), 'new 내용', '원본 보존');
// 쓰기 자체가 실패해도(디스크 가득·권한) 오류를 던지고 rename 은 시도하지 않는다. 임시 파일 정리의 실패는 무시한다
const calls2 = []; const failWrite = { writeFileSync: () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); }, renameSync: () => calls2.push('rename'), unlinkSync: () => { throw new Error('ENOENT'); } };
assert.throws(() => ve.writeTextAtomic(f, 'x', { fs: failWrite }), /EACCES/); assert.deepEqual(calls2, []);
assert.throws(() => ve.writeTextAtomic('', 'x'), /경로/); assert.throws(() => ve.writeTextAtomic(f, 5), /문자열/);
fs.rmSync(dir, { recursive: true, force: true });
});
// ---------- 화면 배치 계약(실제 브라우저 시각 검증에서 발견한 차단 결함의 회귀) ----------
/**
* 작은 CSS 규칙 파서: 주석을 지우고 중괄호 깊이로 규칙과 @media 블록을 나눈다. 반환: [{ media: '(max-width: 900px)'|null, selector, decls: {속성: 값} }]
* 선택자·속성 값으로 계약을 검사하기 위한 것이며 주석 문자열은 보지 않는다.
*/
function parseCss(css) {
const src = css.replace(/\/\*[\s\S]*?\*\//g, ''); const out = [];
const walk = (text, media) => {
let i = 0;
while (i < text.length) {
const open = text.indexOf('{', i); if (open < 0) break;
const head = text.slice(i, open).trim();
let depth = 1; let j = open + 1; while (j < text.length && depth > 0) { if (text[j] === '{') depth++; else if (text[j] === '}') depth--; j++; }
const inner = text.slice(open + 1, j - 1);
if (head.startsWith('@media')) walk(inner, head.replace(/^@media\s*/, ''));
else if (!head.startsWith('@')) { const decls = {}; for (const d of inner.split(';')) { const k = d.indexOf(':'); if (k > 0) decls[d.slice(0, k).trim()] = d.slice(k + 1).trim(); } for (const sel of head.split(',')) out.push({ media, selector: sel.trim().replace(/\s+/g, ' '), decls }); }
i = j;
}
};
walk(src, null); return out;
}
const CSS_RULES = parseCss(fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8'));
const rulesFor = (selector, media = null) => CSS_RULES.filter((r) => r.selector === selector && (media === null ? r.media === null : (r.media || '').includes(media)));
const lastDecl = (selector, prop, media = null) => { const rs = rulesFor(selector, media).filter((r) => prop in r.decls); return rs.length ? rs[rs.length - 1].decls[prop] : undefined; };
test('style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px', () => {
// 기본 배치(직접 편집 꺼짐)는 그대로: 레일 · 작업 칸 460px · 캔버스
assert.equal(lastDecl('.app', 'grid-template-columns'), 'var(--rail-w) var(--left-w, 460px) 1fr');
// 직접 편집 켜짐: .left 숨김 + .app 두 칸(레일 + 1fr). 작업 칸 폭(460px·--left-w)이 남아 있으면 캔버스가 좁아진다
assert.equal(lastDecl('body.ve-on .left', 'display'), 'none', '직접 편집 중 옛 작업 칸 숨김');
const cols = lastDecl('body.ve-on .app', 'grid-template-columns'); assert.ok(cols, 'body.ve-on .app 규칙');
assert.ok(/var\(--rail-w\)/.test(cols) && /1fr/.test(cols) && !/460|--left-w/.test(cols), '레일 + 전체 폭: ' + cols);
// 1280px 폭 계산: 레일 232 + 팔레트 + 캔버스 + 속성 창. 양쪽 패널을 펼친 채 캔버스 ≥ 500px
const px = (v) => parseFloat(String(v || '').match(/(\d+(?:\.\d+)?)px/) ? String(v).match(/(\d+(?:\.\d+)?)px/)[1] : 'NaN');
const rail = px(lastDecl(':root', '--rail-w')); const side = px((lastDecl('.ve-side', 'flex') || '').split(/\s+/).pop());
assert.ok(rail > 0 && side > 0, `rail=${rail} side=${side}`);
assert.ok(1280 - rail - 2 * side >= 500, `1280px 에서 캔버스 폭 ${1280 - rail - 2 * side}px (레일 ${rail} + 패널 ${side}×2)`);
// 1180px 이하(레일 204px)에서도 패널을 좁혀 캔버스 ≥ 500px 을 지킨다 — 1100px 기준
const railNarrow = px(lastDecl(':root', '--rail-w', '(max-width: 1180px)')) || rail; const sideNarrow = px((lastDecl('body.ve-on .ve-side', 'flex-basis', '(max-width: 1180px)') || '')) || side;
assert.ok(1100 - railNarrow - 2 * sideNarrow >= 500, `1100px 에서 캔버스 폭 ${1100 - railNarrow - 2 * sideNarrow}px`);
});
test('style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다', () => {
const m = '(max-width: 900px)';
assert.equal(lastDecl('.app', 'grid-template-rows', m), 'auto auto 70vh', '기본(꺼짐) 배치: 레일·작업 칸·캔버스 세 행');
const rows = lastDecl('body.ve-on .app', 'grid-template-rows', m); assert.ok(rows, '좁은 화면 body.ve-on .app 규칙');
const tracks = rows.split(/\s+(?![^()]*\))/); assert.equal(tracks.length, 2, '레일 + 캔버스 두 행: ' + rows); assert.equal(tracks[0], 'auto'); assert.ok(/fr|minmax|vh/.test(tracks[1]), '캔버스 행이 남은 높이를 차지: ' + rows);
assert.equal(lastDecl('.ve-stage', 'flex-direction', m), 'column', '무대는 세로 쌓기');
assert.equal(lastDecl('.ve-stage .frame-wrap', 'order', m), '1', '캔버스가 맨 위'); assert.equal(lastDecl('.ve-side.ve-palette', 'order', m), '2'); assert.equal(lastDecl('.ve-inspector', 'order', m), '3');
});
/**
* 캔버스 런타임(wm-ve-canvas.js)이 문서 head 에 끼워 넣는 CSS 를 소스에서 꺼내 파싱한다.
* `${MARK}` 보간은 실제 값(data-wm-ve-)으로 바꾼다. 주석이 아니라 선택자·선언을 검사하기 위한 것.
*/
function canvasRuntimeCss() {
const src = fs.readFileSync(path.join(ROOT, 'public', 'wm-ve-canvas.js'), 'utf8');
const m = src.match(/st\.textContent = `([\s\S]*?)`;/); assert.ok(m, '런타임 CSS 블록(st.textContent)');
return parseCss(m[1].replace(/\$\{MARK\}/g, 'data-wm-ve-'));
}
/**
* `hidden` 속성이 켜진 `.클래스` 요소의 최종 display 값을 계산한다(브라우저 cascade 의 단순 모형).
* 사용자 에이전트 규칙 `[hidden]{display:none}` 은 작성자 규칙(어떤 명시도든)에 밀리므로,
* 작성자 규칙 중 숨긴 요소에 맞는 것(`.c`, `.c[hidden]`; `.c:not([hidden])` 은 제외)이 display 를 정하면 그 값이 이긴다.
* 우선순위: !important > 명시도([hidden] 속성 선택자 +1) > 나중 선언. 작성자 규칙이 display 를 정하지 않으면 UA 의 none.
*/
function hiddenDisplayOf(rules, cls) {
const esc = cls.replace(/\./g, '\\.');
const matches = (sel) => new RegExp(`^${esc}(\\[hidden\\])?$`).test(sel);
let best = null;
rules.forEach((r, order) => {
if (r.media !== null || !matches(r.selector) || !('display' in r.decls)) return;
const important = /!important/.test(r.decls.display); const spec = /\[hidden\]/.test(r.selector) ? 1 : 0;
const cand = { value: r.decls.display.replace(/\s*!important/, '').trim(), key: [important ? 1 : 0, spec, order] };
const gt = (a, b) => { for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] > b[i]; } return false; }; // 사전식 비교
if (!best || gt(cand.key, best.key)) best = cand;
});
return best ? best.value : 'none';
}
test('wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다', () => {
const rules = canvasRuntimeCss();
// 선택 도구 줄은 renderOverlay() 가 hidden=false 로 바꾸기 전까지 보이면 안 된다.
// `.wm-ve-tools{display:flex}` 같은 작성자 규칙은 UA 의 `[hidden]{display:none}` 을 덮어써 선택 전에도 어두운 도구 줄이 떠 버린다.
assert.equal(hiddenDisplayOf(rules, '.wm-ve-tools'), 'none', 'hidden 인 .wm-ve-tools 의 최종 display');
assert.equal(hiddenDisplayOf(rules, '.wm-ve-drop'), 'none', 'hidden 인 .wm-ve-drop 의 최종 display');
// 보일 때(hidden 아님)는 도구 줄이 가로 배치(flex)여야 버튼이 한 줄로 놓인다
const shown = rules.filter((r) => r.media === null && /^\.wm-ve-tools(:not\(\[hidden\]\))?$/.test(r.selector) && 'display' in r.decls);
assert.ok(shown.some((r) => /^flex$/.test(r.decls.display)), '보이는 .wm-ve-tools 는 display:flex');
});
/** 가짜 DOM 요소: 화면 코드가 쓰는 최소 표면(classList·속성·innerHTML·querySelectorAll·이벤트) */
function fakeEl(id) {
const cls = new Set(); const attrs = {};
return { id, classList: { add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)), toggle: (c, on) => { (on === undefined ? !cls.has(c) : on) ? cls.add(c) : cls.delete(c); }, contains: (c) => cls.has(c) }, setAttribute: (k, v) => { attrs[k] = String(v); }, getAttribute: (k) => (k in attrs ? attrs[k] : null), removeAttribute: (k) => { delete attrs[k]; }, hasAttribute: (k) => k in attrs,
textContent: '', innerHTML: '', className: '', disabled: false, value: '', hidden: false, style: {}, childElementCount: 0, children: [], dataset: {}, contentWindow: {}, _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, appendChild() { this.childElementCount++; }, querySelectorAll: () => [], focus() {}, click() {},
fire(t, ev) { (this._ev[t] || []).forEach((fn) => fn(ev)); return ev; }, _captured: null, setPointerCapture(id) { this._captured = id; }, releasePointerCapture() { this._captured = null; }, closest() { return null; }, clientLeft: 0, clientTop: 0, getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }; } };
}
/** 빌더 쪽 제어(visual-editor-ui.js)를 가짜 DOM 위에서 실행해 VisualEditor 객체와 document 를 돌려준다 */
function loadUi(apiResponse) {
const els = {}; const $ = (id) => (els[id] || (els[id] = fakeEl(id))); const created = [];
const document = { body: fakeEl('body'), readyState: 'complete', getElementById: $, createElement: (t) => { const e = fakeEl(t); e.tagName = t; created.push(e); return e; }, _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, fire(t, ev) { (this._ev[t] || []).forEach((fn) => fn(ev)); return ev; }, activeElement: null, querySelectorAll: () => [], created };
const ctx = { document, console, setTimeout, clearTimeout, confirm: () => (ctx.answer === undefined ? true : ctx.answer), alert: (m) => { throw new Error('alert: ' + m); }, localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
$, T: (s) => s, calls: [], api: async (p, o) => { ctx.calls.push({ path: p, method: (o && o.method) || 'GET' }); return apiResponse; }, current: { id: 'a'.repeat(32), files: ['index.html'], url: 'http://h/a/' }, ed: { open: false, dirty: false }, setEditorOpen() {}, renderGit() {}, loadFrame() { ctx.loaded = (ctx.loaded || 0) + 1; }, logLine() {}, pushMsg() {} };
ctx.window = ctx; ctx._winEv = {}; ctx.window.addEventListener = (t, fn) => { (ctx._winEv[t] || (ctx._winEv[t] = [])).push(fn); }; ctx.window.fire = (t, ev) => { (ctx._winEv[t] || []).forEach((fn) => fn(ev)); return ev; }; ctx.window.matchMedia = () => ({ matches: false });
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-ui.js'), 'utf8'), ctx);
return { ve: ctx.VisualEditor, document, els, ctx };
}
/**
* 가짜 MessageChannel: 두 포트가 서로 짝이고 postMessage 는 구조 복제(JSON) 뒤 상대 onmessage 를 바로(동기) 부른다.
* 브라우저의 MessagePort 처럼 message 이벤트에 source/origin 이 없다(포트는 만든 문서에 묶인다). close() 뒤에는 어느 쪽도 전달되지 않는다.
*/
function fakeChannel() {
const mk = () => ({ onmessage: null, peer: null, closed: false, started: false, postMessage(m) { const p = this.peer; if (!p || this.closed || p.closed) return; const data = JSON.parse(JSON.stringify(m)); if (typeof p.onmessage === 'function') p.onmessage({ data, origin: '', source: null, ports: [] }); }, start() { this.started = true; }, close() { this.closed = true; } });
const port1 = mk(); const port2 = mk(); port1.peer = port2; port2.peer = port1; return { port1, port2 };
}
/** 캔버스가 보낸 것처럼 ready(+포트) 창 메시지를 빌더 쪽에 쏘아 연결한다. sent = 빌더가 포트로 보낸 명령, fromCanvas = 캔버스가 포트로 보내는 이벤트 */
function connectCanvas({ document, ctx }, session) {
const frameEl = document.getElementById('frame'); const ch = fakeChannel(); const sent = []; ch.port1.onmessage = (e) => sent.push(e.data);
ctx.window.fire('message', { data: core.makeMessage(session, 'ready', { title: 't' }), source: frameEl.contentWindow, origin: 'null', ports: [ch.port2] });
return { sent, ch, fromCanvas: (type, payload) => ch.port1.postMessage(core.makeMessage(session, type, payload)) };
}
test('visual-editor-ui.js 연결 규약: 첫 ready 한 번으로 MessagePort 에 연결해 모든 명령을 포트로만 보내고(창 메시지 없음), 두 번째 ready(바뀐 문서)·창으로 온 changed/hotkey 는 보낸 창·출처·세션이 맞아도 무시하며, 끝내면 포트를 닫는다', async () => {
const SESSION = 'ab'.repeat(16); const ui = loadUi({ session: SESSION, hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
const { ve, document, ctx } = ui; const frameEl = document.getElementById('frame'); const winSent = []; frameEl.contentWindow.postMessage = (m) => winSent.push(m);
await ve.open('index.html');
const viaWindow = (type, payload) => ctx.window.fire('message', { data: core.makeMessage(SESSION, type, payload), source: frameEl.contentWindow, origin: 'null' });
// 연결 전: 포트 없는 ready·changed 창 메시지는 연결도 변경도 만들지 않는다
viaWindow('ready', {}); viaWindow('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), false); assert.equal(winSent.length, 0);
// 연결: ready + 포트 → init 은 포트로, 창(targetOrigin '*')으로는 아무것도 보내지 않는다
const c = connectCanvas(ui, SESSION);
assert.deepEqual(c.sent.map((m) => m.type), ['init']); assert.equal(c.sent[0].session, SESSION); assert.equal(winSent.length, 0, '세션이 든 명령을 창으로 보내지 않는다');
c.fromCanvas('body', { body: '<p>x</p>' }); c.fromCanvas('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), true, '포트로 온 변경은 처리');
// 두 번째 ready(iframe 안 문서가 바뀜 — 같은 contentWindow·출처 null·세션까지 맞음) → 연결을 바꾸지 않는다
const ch2 = fakeChannel(); const sent2 = []; ch2.port1.onmessage = (e) => sent2.push(e.data);
ctx.window.fire('message', { data: core.makeMessage(SESSION, 'ready', {}), source: frameEl.contentWindow, origin: 'null', ports: [ch2.port2] });
ve.undo(); assert.equal(c.sent.at(-1).type, 'applyHtml', '명령은 첫 포트로'); assert.equal(sent2.length, 0, '새 포트로는 아무것도 가지 않는다'); assert.equal(winSent.length, 0);
// 창으로 온 changed·hotkey(save)·body 는 무시: 기록·저장 호출 없음
const h0 = ve.state().save; const calls0 = ctx.calls.length; viaWindow('changed', { body: '<p>z</p>' }); viaWindow('hotkey', { key: 'save' }); viaWindow('body', { body: '<p>q</p>' });
assert.equal(ve.state().save, h0); assert.equal(ctx.calls.length, calls0, '창 메시지로 저장(PUT)이 일어나지 않는다'); assert.equal(c.sent.filter((m) => m.type === 'applyHtml').length, 1);
// 포트로 온 hotkey 는 처리된다(정상 런타임은 계속 동작)
c.fromCanvas('hotkey', { key: 'undo' }); assert.ok(c.sent.filter((m) => m.type === 'applyHtml').length >= 1);
// 세션이 다른 포트 메시지는 무시
c.ch.port1.postMessage(core.makeMessage('other', 'changed', { body: '<p>w</p>' })); assert.notEqual(ve.state().save, 'dirty-from-other');
// 끝내기 → 포트 닫힘, 이후 포트 메시지는 아무 효과 없음; 다시 열면 새 ready 가 다시 한 번 받아들여진다
ve.close(true); assert.equal(c.ch.port2.closed, true, '끝내면 포트를 닫는다'); c.fromCanvas('changed', { body: '<p>v</p>' }); assert.equal(ve.isDirty(), false);
await ve.open('index.html'); const c2 = connectCanvas(ui, SESSION); assert.deepEqual(c2.sent.map((m) => m.type), ['init']); assert.equal(winSent.length, 0);
ve.close(true);
});
test('visual-editor-ui.js onSiteUpdated: 저장하지 않은 변경이 있으면 다른 사이트·사라진 페이지·HTML 없는 갱신에도 묻지 않고 끝내지 않는다 — 취소하면 편집기·사이트를 그대로 두고, 승낙하면 끝낸다', async () => {
const SESSION = 'ab'.repeat(16); const ui = loadUi({ session: SESSION, hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
const { ve, ctx } = ui; await ve.open('index.html'); const c = connectCanvas(ui, SESSION); c.fromCanvas('body', { body: '<p>x</p>' }); c.fromCanvas('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), true);
ctx.answer = false; let asked = 0; ctx.confirm = () => { asked++; return ctx.answer; };
ve.onSiteUpdated({ id: 'b'.repeat(32), files: ['index.html'] }); assert.equal(ve.isActive(), true, '다른 사이트 갱신: 취소하면 유지'); assert.equal(ve.state().siteId, 'a'.repeat(32)); assert.equal(asked, 1);
ve.onSiteUpdated({ id: 'a'.repeat(32), files: [] }); assert.equal(ve.isActive(), true, '페이지가 사라진 갱신: 취소하면 유지'); assert.equal(ve.isDirty(), true); assert.equal(asked, 2);
ve.onSiteUpdated(null); assert.equal(ve.isActive(), true); assert.equal(asked, 3);
ctx.answer = true; ve.onSiteUpdated({ id: 'b'.repeat(32), files: [] }); assert.equal(ve.isActive(), false, '승낙하면 끝낸다'); assert.equal(asked, 4);
// 변경이 없으면 묻지 않고 끝낸다(잃을 것이 없다)
await ve.open('index.html'); const c2 = connectCanvas(ui, SESSION); c2.fromCanvas('body', { body: '<p>x</p>' }); asked = 0; ve.onSiteUpdated({ id: 'b'.repeat(32), files: [] }); assert.equal(ve.isActive(), false); assert.equal(asked, 0);
});
test('visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다', async () => {
const { ve, document, els, ctx } = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
assert.ok(ve && typeof ve.open === 'function');
assert.equal(document.body.classList.contains('ve-on'), false);
await ve.open('index.html');
assert.equal(ve.isActive(), true); assert.equal(document.body.classList.contains('ve-on'), true, '직접 편집 중 body.ve-on — CSS 가 작업 칸을 숨기고 .app 을 다시 배치하는 근거');
for (const id of ['veBar', 'vePalette', 'veInspector']) assert.equal(els[id].classList.contains('hidden'), false, id + ' 보임');
assert.equal(els.btnVisual.getAttribute('aria-pressed'), 'true'); assert.equal(els.frame.srcdoc.includes('<p>x</p>'), true, '캔버스 문서가 iframe 에 들어감');
assert.equal(ve.state().siteId, ctx.current.id); assert.equal(ve.state().file, 'index.html');
assert.equal(ve.close(false), true);
assert.equal(ve.isActive(), false); assert.equal(document.body.classList.contains('ve-on'), false, '끝내면 body.ve-on 제거 → 옛 배치 복원');
for (const id of ['veBar', 'vePalette', 'veInspector']) assert.equal(els[id].classList.contains('hidden'), true, id + ' 숨김');
assert.equal(els.btnVisual.getAttribute('aria-pressed'), 'false'); assert.equal(els.frame.getAttribute('srcdoc'), null); assert.ok(ctx.loaded >= 1, '미리보기를 저장된 파일로 다시 읽는다');
// 다시 켜고 강제 종료(사이트 전환)해도 같은 결과
await ve.open('index.html'); assert.equal(document.body.classList.contains('ve-on'), true); ve.close(true); assert.equal(document.body.classList.contains('ve-on'), false);
});
// ---------- 공용 핵심(public/visual-editor-core.js) ----------
test('core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다', () => {
const dt = (data) => ({ types: Object.keys(data), getData: (k) => (k in data ? data[k] : '') });
assert.equal(core.COMPONENT_MIME, 'text/wm-ve-component');
assert.equal(core.componentDragText('paragraph'), 'wm-ve-component:paragraph');
// 끌기 인식: 전용 MIME 또는 text/plain 이 있으면(dragover 때는 getData 를 읽을 수 없으므로 종류로만 판단)
assert.equal(core.isComponentDrag(['text/wm-ve-component']), true); assert.equal(core.isComponentDrag(['text/plain']), true);
assert.equal(core.isComponentDrag(['Files']), false); assert.equal(core.isComponentDrag(['text/html', 'text/uri-list']), false); assert.equal(core.isComponentDrag(undefined), false);
// 전용 MIME 우선
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'heading', 'text/plain': 'wm-ve-component:paragraph' })), 'heading');
// 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 복원
assert.equal(core.dragComponentId(dt({ 'text/plain': 'wm-ve-component:paragraph' })), 'paragraph');
for (const c of core.COMPONENTS) assert.equal(core.dragComponentId(dt({ 'text/plain': core.componentDragText(c.id) })), c.id, c.id);
// 거절: 임의 글·주소·HTML, 모르는 id, 정확히 일치하지 않는 값(공백·대문자·접두 변형), 전용 MIME 의 모르는 id
for (const bad of ['https://example.com/', '<img src=x onerror=alert(1)>', 'paragraph', 'wm-ve-component:evil', 'wm-ve-component:paragraph ', ' wm-ve-component:paragraph', 'wm-ve-component:Paragraph', 'WM-VE-COMPONENT:paragraph', 'wm-ve-component:', 'xwm-ve-component:paragraph'])
assert.equal(core.dragComponentId(dt({ 'text/plain': bad })), null, JSON.stringify(bad));
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'evil' })), null);
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'evil', 'text/plain': 'wm-ve-component:paragraph' })), null, '전용 MIME 이 있으면 그 값만 믿는다');
assert.equal(core.dragComponentId(null), null); assert.equal(core.dragComponentId({}), null);
assert.equal(core.dragComponentId({ types: ['text/plain'], getData() { throw new Error('denied'); } }), null, 'getData 예외는 거절로');
});
test('visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류)', async () => {
const { ve, document } = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
await ve.open('index.html');
const items = document.created.filter((e) => e.tagName === 'li' && e.className === 've-comp');
assert.equal(items.length, core.COMPONENTS.length, '팔레트 항목 수');
items.forEach((li, i) => {
assert.ok(li._ev.dragstart && li._ev.dragstart.length, 'dragstart 처리기');
const data = {}; const e = { dataTransfer: { setData: (k, v) => { data[k] = String(v); }, effectAllowed: '' } };
li._ev.dragstart.forEach((fn) => fn(e));
const id = core.COMPONENTS[i].id;
assert.equal(data['text/wm-ve-component'], id, '전용 MIME 유지');
assert.equal(data['text/plain'], core.componentDragText(id), 'text/plain 은 이름 공간 + 검증된 id');
assert.equal(e.dataTransfer.effectAllowed, 'copy');
});
});
/**
* 캔버스 런타임(wm-ve-canvas.js)을 실행할 수 있는 작은 가짜 DOM.
* 런타임이 쓰는 표면만 구현한다: 요소 트리(appendChild·before·after·insertBefore·remove·cloneNode), 속성·classList·style,
* innerHTML/outerHTML(간단한 HTML 파서·직렬화), querySelectorAll/closest/matches(태그·[속성]·[속성=값]·쉼표 목록), getBoundingClientRect(테스트가 지정),
* document(createElement·template.content·elementFromPoint·addEventListener), window.parent.postMessage(메시지 기록).
*/
function canvasDom(bodyHtml) {
const VOID = new Set(['img', 'hr', 'br', 'input', 'meta', 'link', 'base', 'source', 'area']);
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
class FNode {
constructor(tag) { this.nodeType = 1; this.tagName = tag.toUpperCase(); this._attrs = new Map(); this._kids = []; this._text = ''; this.parentElement = null; this.style = {}; this._ev = {}; this.hidden = false; this._rect = null;
const self = this; const cls = () => (self.getAttribute('class') || '').split(/\s+/).filter(Boolean); const set = (a) => { if (a.length) self.setAttribute('class', a.join(' ')); else self.removeAttribute('class'); };
this.classList = { add: (...c) => set([...new Set([...cls(), ...c])]), remove: (...c) => set(cls().filter((x) => !c.includes(x))), toggle: (c, on) => { const has = cls().includes(c); const want = on === undefined ? !has : !!on; if (want && !has) set([...cls(), c]); if (!want && has) set(cls().filter((x) => x !== c)); return want; }, contains: (c) => cls().includes(c), get length() { return cls().length; }, [Symbol.iterator]() { return cls()[Symbol.iterator](); } }; }
get className() { return this.getAttribute('class') || ''; } set className(v) { if (v) this.setAttribute('class', v); else this.removeAttribute('class'); }
get id() { return this.getAttribute('id') || ''; }
get attributes() { return [...this._attrs].map(([name, value]) => ({ name, value })); }
setAttribute(k, v) { this._attrs.set(k, String(v)); } getAttribute(k) { return this._attrs.has(k) ? this._attrs.get(k) : null; } hasAttribute(k) { return this._attrs.has(k); } removeAttribute(k) { this._attrs.delete(k); }
get children() { return [...this._kids]; } get firstElementChild() { return this._kids[0] || null; }
get nextElementSibling() { const p = this.parentElement; if (!p) return null; return p._kids[p._kids.indexOf(this) + 1] || null; }
get textContent() { return this._text + this._kids.map((k) => k.textContent).join(''); } set textContent(v) { this._text = String(v); this._kids = []; }
get innerHTML() { return this._text + this._kids.map((k) => k.outerHTML).join(''); } set innerHTML(v) { this._text = ''; this._kids = []; parse(String(v)).forEach((k) => this.appendChild(k)); }
get outerHTML() { const t = this.tagName.toLowerCase(); const a = [...this._attrs].map(([k, v]) => ` ${k}="${esc(v)}"`).join(''); return VOID.has(t) ? `<${t}${a}>` : `<${t}${a}>${this.innerHTML}</${t}>`; }
appendChild(n) { if (n.parentElement) n.remove(); n.parentElement = this; this._kids.push(n); return n; }
insertBefore(n, ref) { if (!ref) return this.appendChild(n); if (n.parentElement) n.remove(); const i = this._kids.indexOf(ref); n.parentElement = this; this._kids.splice(i < 0 ? this._kids.length : i, 0, n); return n; }
before(n) { this.parentElement.insertBefore(n, this); } after(n) { this.parentElement.insertBefore(n, this.nextElementSibling); }
remove() { const p = this.parentElement; if (!p) return; p._kids.splice(p._kids.indexOf(this), 1); this.parentElement = null; }
cloneNode(deep) { const c = new FNode(this.tagName); for (const [k, v] of this._attrs) c._attrs.set(k, v); c._text = this._text; c._rect = this._rect; if (deep) this._kids.forEach((k) => c.appendChild(k.cloneNode(true))); return c; }
contains(n) { return n === this || this._kids.some((k) => k.contains(n)); }
_all() { return this._kids.flatMap((k) => [k, ...k._all()]); }
matches(sel) { return String(sel).split(',').some((s) => { s = s.trim(); const m = s.match(/^([a-zA-Z*][\w-]*)?((?:\[[^\]]+\])*)$/); if (!m) return false; if (m[1] && m[1] !== '*' && m[1].toUpperCase() !== this.tagName) return false; for (const a of (m[2] || '').match(/\[[^\]]+\]/g) || []) { const am = a.match(/^\[([^=\]]+)(?:=["']?([^"'\]]*)["']?)?\]$/); if (!am) return false; if (!this.hasAttribute(am[1])) return false; if (am[2] !== undefined && this.getAttribute(am[1]) !== am[2]) return false; } return true; }); }
closest(sel) { let c = this; while (c && c.nodeType === 1) { if (c.matches(sel)) return c; c = c.parentElement; } return null; }
querySelectorAll(sel) { return this._all().filter((n) => n.matches(sel)); } querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
getBoundingClientRect() { return this._rect || { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; }
scrollIntoView() {} focus() {} addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); } removeEventListener() {}
setPointerCapture(id) { this._captured = id; } releasePointerCapture() { this._captured = null; }
}
function parse(html) {
const root = new FNode('#root'); let cur = root;
const re = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g; let m;
while ((m = re.exec(html))) {
if (m[1]) { if (cur !== root) cur = cur.parentElement || root; }
else if (m[2]) { const el = new FNode(m[2]); const ar = /([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; let a; while ((a = ar.exec(m[3] || ''))) el.setAttribute(a[1], a[2] !== undefined ? a[2] : a[3] !== undefined ? a[3] : a[4] !== undefined ? a[4] : ''); cur.appendChild(el); if (!m[4] && !VOID.has(m[2].toLowerCase())) cur = el; }
else if (m[5] && m[5].trim()) cur._text += m[5].replace(/&quot;/g, '"').replace(/&amp;/g, '&');
}
return [...root._kids]; // 복사본: appendChild 가 원래 부모(root)에서 떼어 내며 목록을 바꾸므로
}
const doc = { readyState: 'complete', title: '테스트', _ev: {}, addEventListener(t, fn) { (doc._ev[t] || (doc._ev[t] = [])).push(fn); }, removeEventListener() {}, execCommand() {},
createElement: (t) => { const n = new FNode(t); if (t === 'template') n.content = n; return n; }, elementFromPoint: () => null, querySelectorAll: (s) => doc.body.querySelectorAll(s) };
doc.documentElement = new FNode('html'); doc.head = new FNode('head'); doc.body = new FNode('body'); doc.documentElement.appendChild(doc.head); doc.documentElement.appendChild(doc.body); doc.body.innerHTML = bodyHtml;
return doc;
}
/** 캔버스 런타임을 가짜 DOM 위에서 부트하고, 이벤트를 쏘는 fire()·부모가 받은 메시지·본문 자식 목록을 돌려준다 */
function loadCanvas(bodyHtml) {
const doc = canvasDom(bodyHtml); const messages = []; const transfers = []; let parentPort = null;
const ctx = { document: doc, console, setTimeout, clearTimeout, getComputedStyle: () => ({ display: 'block', textAlign: 'start' }), __WM_VE__: { session: 'sess-1234', parentOrigin: 'http://h' }, scrollX: 0, scrollY: 0, innerWidth: 800, innerHeight: 600, _ev: {},
addEventListener(t, fn) { (ctx._ev[t] || (ctx._ev[t] = [])).push(fn); }, removeEventListener(t, fn) { ctx._ev[t] = (ctx._ev[t] || []).filter((f) => f !== fn); }, MessageChannel: function () { const ch = fakeChannel(); this.port1 = ch.port1; this.port2 = ch.port2; } };
ctx.window = ctx;
/** 부모 창: ready 와 함께 넘어온 포트를 받아 그 뒤 캔버스가 포트로 보내는 모든 이벤트를 messages 에 쌓는다 */
ctx.parent = { postMessage: (m, origin, transfer) => { messages.push(m); transfers.push({ origin, ports: transfer || [] }); if (transfer && transfer[0]) { parentPort = transfer[0]; parentPort.onmessage = (e) => messages.push(e.data); } } };
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'wm-ve-canvas.js'), 'utf8'), ctx, { filename: 'wm-ve-canvas.js' });
const fire = (type, ev) => { ev.defaultPrevented = false; ev.preventDefault = () => { ev.defaultPrevented = true; }; ev.target = ev.target || doc.documentElement; (doc._ev[type] || []).forEach((fn) => fn(ev)); return ev; };
const fireWin = (type, ev) => { ev = ev || {}; ev.preventDefault = () => { ev.defaultPrevented = true; }; (ctx._ev[type] || []).slice().forEach((fn) => fn(ev)); return ev; };
const fireOn = (el, type, ev) => { ev = ev || {}; ev.preventDefault = () => { ev.defaultPrevented = true; }; ev.target = ev.target || el; (el._ev[type] || []).forEach((fn) => fn(ev)); return ev; };
/** 부모가 연결된 포트로 명령을 보낸다(세션 일치). over.data 로 봉투를 통째로 바꿀 수 있다 */
const fromParent = (type, payload, over) => { const data = over && over.data ? over.data : core.makeMessage('sess-1234', type, payload); if (!parentPort) throw new Error('포트가 연결되지 않음'); parentPort.postMessage(data); return data; };
/** 창(window.postMessage) 으로 온 명령 — 보낸 창·출처·세션이 모두 맞는, 예전 규약의 메시지. 연결 규약에서는 처리되지 않아야 한다 */
const fromWindow = (type, payload) => { const ev = { data: core.makeMessage('sess-1234', type, payload), source: ctx.parent, origin: 'http://h' }; (ctx._ev.message || []).forEach((fn) => fn(ev)); return ev; };
const kids = () => doc.body.children.filter((c) => !c.hasAttribute('data-wm-ve-ui'));
const dropBar = () => doc.body.children.find((c) => c.classList.contains('wm-ve-drop'));
const tools = () => doc.body.children.find((c) => c.classList.contains('wm-ve-tools'));
return { doc, ctx, messages, transfers, port: () => parentPort, fire, fireWin, fireOn, fromParent, fromWindow, kids, dropBar, tools };
}
test('wm-ve-canvas.js 연결 규약: ready 와 함께 MessagePort 를 부모 출처로 넘기고 그 뒤 명령은 포트로만 받는다 — 창으로 온 명령은 보낸 창·출처·세션이 맞아도 처리하지 않고, 세션이 다른 포트 메시지도 무시한다', () => {
const { messages, transfers, port, fromParent, fromWindow, kids } = loadCanvas('<h1>제목</h1>');
const ready = messages.find((m) => m.type === 'ready'); assert.ok(ready, 'ready'); assert.equal(transfers[0].origin, 'http://h', 'ready 는 부모 출처로만'); assert.ok(port(), 'ready 와 함께 포트가 넘어온다');
const n0 = kids().length; const m0 = messages.length;
fromWindow('insert', { component: 'paragraph', where: 'end' }); fromWindow('requestBody', {});
assert.equal(kids().length, n0, '창 명령으로는 아무것도 넣지 않는다'); assert.equal(messages.slice(m0).length, 0, '창 명령에 응답(body)하지 않는다');
fromParent('requestBody', {}); assert.equal(messages.at(-1).type, 'body', '포트 명령에는 응답'); assert.equal(messages.at(-1).session, 'sess-1234');
fromParent('insert', { component: 'paragraph', where: 'end' }); assert.equal(kids().length, n0 + 1, '포트 명령은 처리'); assert.equal(messages.at(-1).type, 'changed');
const m1 = messages.length; fromParent('insert', { component: 'paragraph', where: 'end' }, { data: core.makeMessage('other', 'insert', { component: 'paragraph', where: 'end' }) }); fromParent('x', {}, { data: { wmve: 1, session: 'sess-1234', type: 'evil', payload: {} } }); fromParent('x', {}, { data: 'insert' });
assert.equal(kids().length, n0 + 1, '세션·종류·형식이 틀린 포트 메시지는 무시'); assert.equal(messages.slice(m1).length, 0);
});
test('wm-ve-canvas.js: 캔버스 안의 이동·제출 활성화(a·area href 클릭, 제출 버튼, 폼 submit)는 편집·미리보기 모드 모두에서 막힌다', () => {
const { doc, fire, fromParent } = loadCanvas('<map name="m"><area href="x.html" shape="rect" coords="0,0,1,1"></map><a href="y.html">링크</a><form action="z.html"><button type="submit">보내기</button><input type="submit" value="s"></form>');
const area = doc.body.querySelector('area'); const a = doc.body.querySelector('a'); const btn = doc.body.querySelector('button'); const inp = doc.body.querySelector('input'); const form = doc.body.querySelector('form');
for (const mode of ['edit', 'preview']) {
fromParent('setMode', { mode });
for (const el of [area, a, btn, inp]) assert.equal(fire('click', { target: el }).defaultPrevented, true, `${mode}: ${el.tagName} 클릭 기본 동작 차단`);
// 가운데 버튼(auxclick)의 기본 동작은 "새 탭에서 열기" — 실제 Chrome 에서 sandbox(allow-popups 없음) 프레임의 링크 가운데 클릭이 새 페이지를 만든 것을 게이트가 관찰했다 → auxclick 도 막는다
for (const el of [area, a]) assert.equal(fire('auxclick', { target: el, button: 1 }).defaultPrevented, true, `${mode}: ${el.tagName} 가운데 클릭(auxclick) 차단`);
assert.equal(fire('submit', { target: form }).defaultPrevented, true, `${mode}: submit 차단`);
}
// 활성화 요소가 아닌 곳의 auxclick 은 건드리지 않는다
assert.equal(fire('auxclick', { target: doc.body, button: 1 }).defaultPrevented, false);
});
test('prepareCanvas: 이동 활성화 방어 — meta refresh 는 보존 속성으로 바뀌고, CSP 가 form-action 을 막으며, 캔버스 base 는 target=_blank(sandbox 에서 새 창 이동은 열리지 않는다)', () => {
const html = ve.prepareCanvas('<!doctype html><html><head><meta http-equiv="refresh" content="0;url=https://e/"></head><body><a href="x.html">x</a></body></html>', OPTS);
assert.ok(!/<meta http-equiv="refresh"/.test(html) && html.includes('data-wm-ve-http-equiv="refresh"'), 'meta refresh 비활성화');
assert.match(html, /Content-Security-Policy" content="[^"]*form-action 'none'/);
assert.match(html, /<base href="http:\/\/h\/abc\/" target="_blank" data-wm-ve-ui>/);
});
test('wm-ve-canvas.js duplicate: 복제본은 뿌리뿐 아니라 모든 자손의 id 도 지우고, 원본의 id 는 그대로 둔다', () => {
const { doc, fromParent, kids, messages } = loadCanvas('<section id="s"><h2 id="t">x</h2><p>y<span id="u">z</span></p></section>');
fromParent('select', { path: [0] }); fromParent('duplicate', {});
assert.equal(kids().length, 2); const [orig, copy] = kids();
assert.equal(orig.getAttribute('id'), 's'); assert.equal(orig.querySelector('h2').getAttribute('id'), 't'); assert.equal(orig.querySelector('span').getAttribute('id'), 'u', '원본 그대로');
assert.equal(copy.hasAttribute('id'), false); assert.equal(copy.querySelectorAll('[id]').length, 0, '복제본 자손에 id 없음');
assert.equal(doc.body.querySelectorAll('[id=t]').length, 1); assert.equal(messages.at(-1).type, 'changed'); assert.ok(!/id="t".*id="t"/s.test(messages.at(-1).payload.body));
});
test('wm-ve-canvas.js 기존 블록 끌기: 손잡이 pointerdown 이 포인터를 캡처하고, pointercancel·창 blur·Escape 가 끌기 상태(dragging 클래스·놓기 표시·창 리스너·캡처)를 모두 정리하며 이후 pointerup 은 옮기지 않는다 — 키보드 Alt+↓ 는 그대로', () => {
const { doc, ctx, messages, fire, fireWin, fireOn, fromParent, kids, dropBar, tools } = loadCanvas('<h1>제목</h1><p>본문</p>');
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
fromParent('select', { path: [0] }); const grip = tools().querySelector('[class="wm-ve-grip"]'); assert.ok(grip, '끌기 손잡이');
const winCount = (t) => (ctx._ev[t] || []).length; const base = { pointermove: winCount('pointermove'), pointerup: winCount('pointerup'), pointercancel: winCount('pointercancel'), blur: winCount('blur') };
const start = () => { fireOn(grip, 'pointerdown', { pointerId: 3, clientX: 5, clientY: 5 }); assert.equal(grip._captured, 3, 'setPointerCapture'); assert.equal(h1.classList.contains('wm-ve-dragging'), true); fireWin('pointermove', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(dropBar().hidden, false, '놓기 표시'); };
const clean = (label) => { assert.equal(h1.classList.contains('wm-ve-dragging'), false, label + ': dragging 클래스 제거'); assert.equal(dropBar().hidden, true, label + ': 놓기 표시 숨김'); assert.equal(grip._captured, null, label + ': 캡처 해제'); for (const t of Object.keys(base)) assert.equal(winCount(t), base[t], label + ': 창 리스너 ' + t + ' 정리'); };
const order = () => kids().map((k) => k.tagName).join(',');
// 1) pointercancel
start(); const m1 = messages.length; fireWin('pointercancel', { pointerId: 3 }); clean('pointercancel'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P', '취소 뒤 up 은 옮기지 않는다'); assert.equal(messages.slice(m1).filter((m) => m.type === 'changed').length, 0);
// 2) 창 blur(iframe 밖으로 포커스가 나감)
start(); fireWin('blur', {}); clean('blur'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P');
// 3) Escape
start(); fire('keydown', { key: 'Escape', ctrlKey: false, metaKey: false, altKey: false }); clean('Escape'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P');
// 4) 정상 놓기는 여전히 옮긴다, 뒤처리 동일
start(); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'P,H1', '정상 끌어 옮기기'); clean('pointerup'); assert.equal(messages.at(-1).type, 'changed');
// 5) 키보드 대안 보존
fire('keydown', { key: 'ArrowUp', altKey: true, ctrlKey: false, metaKey: false }); assert.equal(order(), 'H1,P', 'Alt+↑');
});
test('wm-ve-canvas.js applyProps·core.safeUrlInput: 엔티티·제어 문자로 감춘 javascript: 주소는 속성 창 값으로도 들어가지 않는다', () => {
for (const bad of ['javascript&colon;alert(1)', 'java&colon;script:alert(1)', '&#x6a;avascript:alert(1)', 'java\u0000script:alert(1)', '&NewLine;javascript:alert(1)', 'java&bogus;script:alert(1)']) { const r = core.safeUrlInput(bad, 'link'); assert.equal(r.ok, false, bad); }
for (const good of ['https://e/?a=1&amp;b=2', 'about.html?x&colon;y', '#top', 'mailto:a@b.c']) assert.equal(core.safeUrlInput(good, 'link').ok, true, good);
const { doc, fromParent, messages } = loadCanvas('<a href="about.html">x</a>');
fromParent('select', { path: [0] }); const m0 = messages.length; fromParent('setProps', { props: { href: 'javascript&colon;alert(1)' } });
assert.equal(doc.body.querySelector('a').getAttribute('href'), 'about.html', 'href 불변'); assert.equal(messages.slice(m0).filter((m) => m.type === 'error').length, 1); assert.equal(messages.slice(m0).filter((m) => m.type === 'changed').length, 0);
});
test('core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증', () => {
for (const t of ['paletteDragAt', 'paletteDragCancel', 'paletteDropAt']) assert.ok(core.MESSAGE_TYPES.toCanvas.includes(t), t + ' 는 부모→캔버스 종류');
assert.ok(core.POINTER_DRAG_THRESHOLD >= 3 && core.POINTER_DRAG_THRESHOLD <= 10, '끌기 시작 문턱(px)');
// iframe 의 화면 사각형(left 300, top 100, 폭 600, 높이 400) + 테두리 12px(모바일 미리보기 틀) → 안쪽 좌표
const rect = { left: 300, top: 100, width: 624, height: 424, right: 924, bottom: 524 }; const box = { left: 12, top: 12, width: 600, height: 400 };
assert.deepEqual(core.pointInFrame(412, 212, rect, box), { inside: true, x: 100, y: 100 });
assert.deepEqual(core.pointInFrame(312, 112, rect, box), { inside: true, x: 0, y: 0 }, '안쪽 왼쪽 위 모서리');
assert.equal(core.pointInFrame(305, 150, rect, box).inside, false, '테두리 위는 바깥');
assert.equal(core.pointInFrame(10, 10, rect, box).inside, false); assert.equal(core.pointInFrame(913, 300, rect, box).inside, false, '오른쪽 안쪽 경계 밖');
assert.deepEqual(core.pointInFrame(350, 150, { left: 300, top: 100, width: 600, height: 400 }), { inside: true, x: 50, y: 50 }, 'box 생략 = 테두리 0');
// 캔버스 쪽 검증: 유한한 숫자이고 뷰포트 안일 때만
assert.deepEqual(core.canvasPoint(10, 20, 800, 600), { x: 10, y: 20 }); assert.deepEqual(core.canvasPoint(800, 600, 800, 600), { x: 800, y: 600 });
for (const bad of [[-1, 0], [0, -1], [801, 0], [0, 601], [NaN, 0], ['10', 20], [Infinity, 0], [null, 0], [undefined, undefined]]) assert.equal(core.canvasPoint(bad[0], bad[1], 800, 600), null, JSON.stringify(bad));
assert.equal(core.isComponentId('paragraph'), true); assert.equal(core.isComponentId('evil'), false); assert.equal(core.isComponentId(''), false); assert.equal(core.isComponentId(null), false);
});
test('visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리', async () => {
const ui = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false }); const { ve, document, els } = ui;
const frameEl = document.getElementById('frame'); frameEl.contentWindow.postMessage = () => { throw new Error('연결 뒤에는 창 메시지를 쓰지 않는다'); };
frameEl.getBoundingClientRect = () => ({ left: 300, top: 100, width: 600, height: 400, right: 900, bottom: 500 }); frameEl.clientLeft = 0; frameEl.clientTop = 0; frameEl.clientWidth = 600; frameEl.clientHeight = 400; void els;
await ve.open('index.html'); const sent = connectCanvas(ui, 'ab'.repeat(16)).sent; sent.length = 0; // 명령은 MessagePort 로 간다(연결 규약 테스트 참고)
const li = document.created.find((e) => e.tagName === 'li' && e.className === 've-comp'); const compId = core.COMPONENTS[0].id;
assert.ok(li._ev.pointerdown && li._ev.pointermove && li._ev.pointerup, 'pointerdown/move/up 처리기');
const ev = (x, y, over) => Object.assign({ clientX: x, clientY: y, pointerId: 7, pointerType: 'mouse', button: 0, target: li, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }, over || {});
const types = () => sent.map((m) => m.type); const plain = (o) => JSON.parse(JSON.stringify(o)); // vm 컨텍스트 객체는 프로토타입이 달라 JSON 으로 비교
// 1) 바깥에서 누르고 문턱 아래로 움직임 → 메시지 없음, 포인터 캡처는 걸림
li.fire('pointerdown', ev(100, 100)); assert.equal(li._captured, 7, 'setPointerCapture'); li.fire('pointermove', ev(102, 101)); assert.deepEqual(types(), []);
// 2) 문턱 넘어 iframe 바깥 이동 → 아직 메시지 없음(끌기 시작 표시만)
li.fire('pointermove', ev(130, 130)); assert.deepEqual(types(), []); assert.equal(li.classList.contains('ve-comp-dragging'), true); assert.equal(document.body.classList.contains('ve-palette-dragging'), true);
// 3) iframe 안으로 → paletteDragAt(상대 좌표: 400-300, 200-100)
li.fire('pointermove', ev(400, 200)); assert.deepEqual(plain(sent.at(-1)), core.makeMessage('ab'.repeat(16), 'paletteDragAt', { component: compId, x: 100, y: 100 }), '봉투(wmve·세션·종류·payload) 그대로');
// 4) 밖으로 → paletteDragCancel 한 번, 다시 안으로 → paletteDragAt
li.fire('pointermove', ev(10, 10)); assert.equal(types().at(-1), 'paletteDragCancel'); li.fire('pointermove', ev(5, 5)); assert.equal(types().filter((t) => t === 'paletteDragCancel').length, 1, '바깥에서 계속 움직여도 취소는 한 번');
li.fire('pointermove', ev(450, 250)); assert.deepEqual(plain(sent.at(-1).payload), { component: compId, x: 150, y: 150 });
// 5) 안에서 pointerup → paletteDropAt(상대 좌표), 캡처·표시 해제
li.fire('pointerup', ev(460, 260)); assert.equal(sent.at(-1).type, 'paletteDropAt'); assert.deepEqual(plain(sent.at(-1).payload), { component: compId, x: 160, y: 160 });
assert.equal(li._captured, null, 'releasePointerCapture'); assert.equal(li.classList.contains('ve-comp-dragging'), false); assert.equal(document.body.classList.contains('ve-palette-dragging'), false);
const n5 = sent.length; li.fire('pointermove', ev(470, 270)); li.fire('pointerup', ev(470, 270)); assert.equal(sent.length, n5, '끝난 뒤 이벤트는 무시');
// 6) "추가" 버튼 위에서 누르면 끌기를 시작하지 않는다(버튼 클릭 보존)
sent.length = 0; const btn = { tagName: 'BUTTON' }; li.fire('pointerdown', ev(100, 100, { target: { closest: (s) => (s === 'button' ? btn : null) } })); li.fire('pointermove', ev(400, 200)); li.fire('pointerup', ev(400, 200));
assert.deepEqual(types(), [], '버튼에서 시작한 포인터는 끌기 아님'); assert.equal(li._captured, null);
// 7) 짧은 클릭(문턱 미만에서 up) → 메시지 없음
li.fire('pointerdown', ev(100, 100)); li.fire('pointerup', ev(101, 101)); assert.deepEqual(types(), []); assert.equal(li._captured, null);
// 8) 터치 포인터는 다리를 쓰지 않는다(팔레트 스크롤 보존) · 보조 버튼도 아님
li.fire('pointerdown', ev(100, 100, { pointerType: 'touch' })); li.fire('pointermove', ev(400, 200, { pointerType: 'touch' })); assert.deepEqual(types(), []); assert.equal(li._captured, null);
li.fire('pointerdown', ev(100, 100, { button: 2 })); li.fire('pointermove', ev(400, 200, { button: 2 })); li.fire('pointerup', ev(400, 200, { button: 2 })); assert.deepEqual(types(), []);
// 9) Escape 로 취소 → paletteDragCancel, 이후 pointerup 은 아무것도 보내지 않음
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); assert.equal(types().at(-1), 'paletteDragAt');
document.fire('keydown', { key: 'Escape', ctrlKey: false, metaKey: false, preventDefault() {} }); assert.equal(types().at(-1), 'paletteDragCancel'); assert.equal(li._captured, null);
const n9 = sent.length; li.fire('pointerup', ev(400, 200)); assert.equal(sent.length, n9, 'Escape 뒤 up 은 놓기 아님');
// 10) pointercancel(브라우저가 끌기를 가로챔) → 취소 정리
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); li.fire('pointercancel', ev(400, 200)); assert.equal(types().at(-1), 'paletteDragCancel'); assert.equal(li._captured, null); assert.equal(document.body.classList.contains('ve-palette-dragging'), false);
// 11) 포인터 끌기 중 브라우저 기본 HTML5 dragstart 가 나면 막는다(포인터 경로가 주 경로, pointercancel 로 끊기지 않게). 끌기 중이 아니면 HTML5 자료는 그대로 넣는다(대체 경로)
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(130, 130)); const data = {}; const ds = li.fire('dragstart', { dataTransfer: { setData: (k, v) => { data[k] = v; }, effectAllowed: '' }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } });
assert.equal(ds.defaultPrevented, true, '포인터 끌기 중 네이티브 dragstart 차단'); li.fire('pointerup', ev(10, 10));
const data2 = {}; const ds2 = li.fire('dragstart', { dataTransfer: { setData: (k, v) => { data2[k] = v; }, effectAllowed: '' }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } });
assert.equal(ds2.defaultPrevented, false); assert.equal(data2['text/plain'], core.componentDragText(compId), 'HTML5 대체 경로 유지');
// 12) 직접 편집을 끝낸 뒤에는 포인터 다리가 메시지를 보내지 않는다
ve.close(true); sent.length = 0; li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); li.fire('pointerup', ev(400, 200)); assert.deepEqual(types(), []);
});
test('wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다', () => {
const { doc, messages, fromParent, fromWindow, kids, dropBar } = loadCanvas('<h1>제목</h1><p>본문</p>');
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
const snapshot = () => doc.body.innerHTML; const n0 = kids().length;
// 1) 끌기 위치 → 놓기 표시(h1 위쪽 절반 → h1 앞 선)
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); assert.equal(dropBar().hidden, false, '놓기 표시 보임'); assert.equal(dropBar().style.top, '-1px', 'h1 위 선');
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 70 }); assert.equal(dropBar().style.top, '79px', 'p 아래 선(아래쪽 절반)');
// 2) 취소 → 숨김
fromParent('paletteDragCancel', {}); assert.equal(dropBar().hidden, true);
// 3) 잘못된 id·좌표는 표시를 숨기고 아무것도 하지 않는다
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'evil', x: 10, y: 10 }); assert.equal(dropBar().hidden, true, '모르는 id');
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'paragraph', x: -5, y: 10 }); assert.equal(dropBar().hidden, true, '뷰포트 밖 좌표');
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'paragraph', x: '10', y: 10 }); assert.equal(dropBar().hidden, true, '숫자 아닌 좌표');
// 4) 놓기 → h1 앞에 문단 하나, changed 한 번(표식 없음), 새 요소 선택, 표시 숨김
const m0 = messages.length; fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 });
fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
assert.equal(kids().length, n0 + 1); assert.equal(kids()[0].tagName, 'P'); assert.equal(kids()[0].textContent, '여기에 내용을 입력하세요.'); assert.equal(kids()[1], h1); assert.ok(kids()[0].hasAttribute('data-wm-ve-selected'));
const changed = messages.slice(m0).filter((m) => m.type === 'changed'); assert.equal(changed.length, 1); assert.ok(changed[0].payload.body.startsWith('<p>여기에 내용을 입력하세요.</p><h1>제목</h1>'), changed[0].payload.body); assert.ok(!/data-wm-ve-|wm-ve-/.test(changed[0].payload.body));
assert.equal(dropBar().hidden, true); assert.equal(messages.slice(m0).filter((m) => m.type === 'error').length, 0);
// 5) 빈 곳(요소 없음) 놓기 → 본문 끝
const before5 = kids().length; fromParent('paletteDropAt', { component: 'divider', x: 10, y: 500 }); assert.equal(kids().length, before5 + 1); assert.equal(kids().at(-1).tagName, 'HR', '끝에 구분선');
// 6) 잘못된 입력은 아무것도 넣지 않고 표식·메시지도 없다: 모르는 id, 좌표 누락/범위 밖/문자열, payload 없음, 미리보기 모드
const s6 = snapshot(); const n6 = kids().length; const m6 = messages.length;
fromParent('paletteDropAt', { component: 'evil', x: 10, y: 10 }); fromParent('paletteDropAt', { component: 'paragraph' }); fromParent('paletteDropAt', { component: 'paragraph', x: 9999, y: 10 }); fromParent('paletteDropAt', { component: 'paragraph', x: '10', y: '10' }); fromParent('paletteDropAt', {});
assert.equal(kids().length, n6); assert.equal(snapshot(), s6); assert.equal(dropBar().hidden, true);
fromParent('setMode', { mode: 'preview' }); const m6b = messages.length; fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); assert.equal(dropBar().hidden, true, '미리보기 모드: 표시 없음'); fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
assert.equal(kids().length, n6, '미리보기 모드: 삽입 없음'); assert.equal(messages.slice(m6b).filter((m) => m.type === 'changed' || m.type === 'error').length, 0);
fromParent('setMode', { mode: 'edit' });
assert.equal(messages.slice(m6, m6b).filter((m) => m.type === 'changed' || m.type === 'error').length, 0, '잘못된 입력에 changed·error 없음');
// 7) 규약 밖 메시지(다른 세션의 포트 메시지·창으로 온 메시지)는 무시된다
const n7 = kids().length; fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 }, { data: core.makeMessage('other', 'paletteDropAt', { component: 'paragraph', x: 10, y: 10 }) }); fromWindow('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
assert.equal(kids().length, n7, '세션이 다르거나 창으로 오면 무시');
});
test('wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다', () => {
const { doc, messages, fire, kids, dropBar } = loadCanvas('<h1>제목</h1><p>본문</p>');
assert.ok(messages.some((m) => m.type === 'ready'), '런타임 부트'); assert.ok(dropBar() && dropBar().hidden === true, '놓기 표시는 처음엔 숨김');
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
const dt = (data) => ({ types: Object.keys(data), getData: (k) => (k in data ? data[k] : ''), dropEffect: '' });
const snapshot = () => doc.body.innerHTML; const n0 = kids().length; const m0 = messages.length;
// 1) 전용 MIME 없이 text/plain 만 보이는 dragover(브라우저는 drop 전까지 getData 를 막는다) → 끌기로 인식해 기본 동작을 막고 놓기 표시를 보인다
const over = fire('dragover', { dataTransfer: { types: ['text/plain'], getData: () => '' , dropEffect: '' }, clientX: 10, clientY: 10 });
assert.equal(over.defaultPrevented, true, 'dragover preventDefault(놓기 허용)'); assert.equal(over.dataTransfer.dropEffect, 'copy'); assert.equal(dropBar().hidden, false, '놓기 표시 보임');
// 2) drop: 전용 MIME 이 사라지고 text/plain 이름 공간 값만 남은 경우 → 문단 하나가 h1 앞(위쪽 절반에 놓음)에 들어간다
const drop = fire('drop', { dataTransfer: dt({ 'text/plain': 'wm-ve-component:paragraph' }), clientX: 10, clientY: 10 });
assert.equal(drop.defaultPrevented, true);
assert.equal(kids().length, n0 + 1, '요소 하나 추가'); assert.equal(kids()[0].tagName, 'P'); assert.equal(kids()[0].textContent, '여기에 내용을 입력하세요.'); assert.equal(kids()[1], h1, 'h1 앞에 삽입');
assert.equal(dropBar().hidden, true, '놓기 표시 숨김'); assert.ok(kids()[0].hasAttribute('data-wm-ve-selected'), '새 요소 선택');
const changed = messages.slice(m0).filter((m) => m.type === 'changed'); assert.equal(changed.length, 1, 'changed 한 번');
assert.ok(changed[0].payload.body.startsWith('<p>여기에 내용을 입력하세요.</p><h1>제목</h1>'), changed[0].payload.body); assert.ok(!/data-wm-ve-|wm-ve-/.test(changed[0].payload.body), '부모에 보낸 본문에 표식 없음');
// 3) 임의 text/plain(주소·HTML 조각)·모르는 id·정확히 일치하지 않는 값 → 아무것도 넣지 않고 표식도 남기지 않는다
const before = snapshot(); const n1 = kids().length; const m1 = messages.length;
for (const bad of ['https://example.com/x', '<img src=x onerror=alert(1)>', 'wm-ve-component:evil', 'wm-ve-component:paragraph ', 'paragraph']) {
fire('dragover', { dataTransfer: { types: ['text/plain'], getData: () => '', dropEffect: '' }, clientX: 10, clientY: 60 });
const d = fire('drop', { dataTransfer: dt({ 'text/plain': bad }), clientX: 10, clientY: 60 });
assert.equal(kids().length, n1, '추가 없음: ' + JSON.stringify(bad)); assert.equal(snapshot(), before, '본문 불변: ' + JSON.stringify(bad));
assert.equal(dropBar().hidden, true, '놓기 표시 숨김: ' + JSON.stringify(bad)); assert.equal(d.defaultPrevented, true, '브라우저 기본 drop(글 삽입·이동) 차단: ' + JSON.stringify(bad));
}
assert.equal(messages.slice(m1).filter((m) => m.type === 'changed' || m.type === 'error').length, 0, 'changed·error 메시지 없음');
// 4) 전용 MIME 이 남아 있는 경우(같은 출처 등)는 그대로 우선 — 제목(h2) 삽입
fire('drop', { dataTransfer: dt({ 'text/wm-ve-component': 'heading', 'text/plain': 'wm-ve-component:paragraph' }), clientX: 10, clientY: 60 });
assert.equal(kids().length, n1 + 1); assert.ok(kids().some((k) => k.tagName === 'H2' && k.textContent === '새 제목'), '전용 MIME 의 heading 이 우선');
// 5) 구성 요소 끌기가 아닌 것(파일·HTML 만) 은 dragover 도 drop 도 건드리지 않는다
const n2 = kids().length; const s2 = snapshot();
const o5 = fire('dragover', { dataTransfer: { types: ['Files'], getData: () => '', dropEffect: '' }, clientX: 10, clientY: 10 }); assert.equal(o5.defaultPrevented, false);
const d5 = fire('drop', { dataTransfer: dt({ 'text/html': '<b>x</b>', 'text/uri-list': 'https://e/' }), clientX: 10, clientY: 10 }); assert.equal(d5.defaultPrevented, false); assert.equal(kids().length, n2); assert.equal(snapshot(), s2);
});
test('core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다', () => {
const ctx = { window: {} }; ctx.window.window = ctx.window;
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
assert.ok(ctx.window.WMVisualCore && typeof ctx.window.WMVisualCore.createHistory === 'function');
for (const f of ['public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) { const src = fs.readFileSync(path.join(ROOT, f), 'utf8'); new vm.Script(src, { filename: f }); assert.ok(!/\beval\(|new Function\(/.test(src), f + ' 에 eval 없음'); }
});
test('core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도', () => {
const h = core.createHistory(3); h.reset('a');
assert.equal(h.canUndo(), false); assert.equal(h.canRedo(), false);
h.push('b'); h.push('c'); assert.equal(h.current(), 'c'); assert.equal(h.canUndo(), true);
assert.equal(h.undo(), 'b'); assert.equal(h.undo(), 'a'); assert.equal(h.undo(), null); assert.equal(h.canUndo(), false);
assert.equal(h.redo(), 'b'); assert.equal(h.canRedo(), true); h.push('d'); assert.equal(h.canRedo(), false); assert.equal(h.redo(), null);
h.push('e'); h.push('f'); assert.equal(h.size(), 3, '한도를 넘으면 오래된 것부터 버린다'); assert.equal(h.undo(), 'e'); assert.equal(h.undo(), 'd'); assert.equal(h.undo(), null);
h.push('d'); assert.equal(h.current(), 'd', '같은 내용은 중복 기록하지 않는다'); assert.equal(h.canUndo(), false);
});
test('core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시', () => {
const s = core.createSaveState();
assert.equal(s.state(), 'clean'); assert.equal(s.isDirty(), false);
assert.equal(s.beginSave(), false, '변경이 없으면 저장을 시작하지 않는다');
s.markDirty(); assert.equal(s.state(), 'dirty'); assert.match(s.label(), /저장되지 않/);
assert.equal(s.beginSave(), true); assert.equal(s.state(), 'saving'); assert.match(s.label(), /저장 중/);
s.markDirty(); assert.equal(s.state(), 'saving', '저장 중의 추가 변경은 저장 뒤 dirty 로 남는다'); s.saveOk(); assert.equal(s.state(), 'dirty');
s.beginSave(); s.saveOk(); assert.equal(s.state(), 'saved'); assert.match(s.label(), /저장됨/); assert.equal(s.isDirty(), false);
s.markDirty(); s.beginSave(); s.saveFail('네트워크'); assert.equal(s.state(), 'error'); assert.match(s.label(), /저장 실패.*네트워크/); assert.equal(s.isDirty(), true);
s.reset(); assert.equal(s.state(), 'clean');
});
test('core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다', () => {
const frameWin = {}; const session = 's1';
const okEv = { source: frameWin, origin: 'null', data: core.makeMessage(session, 'changed', { body: '<p>x</p>' }) };
const chk = (ev, o = {}) => core.validateMessage(ev, { expectedSource: frameWin, expectedOrigin: 'null', session, direction: 'fromCanvas', ...o });
assert.equal(chk(okEv), true);
assert.equal(chk({ ...okEv, source: {} }), false, '다른 창');
assert.equal(chk({ ...okEv, origin: 'http://evil' }), false, '다른 출처');
assert.equal(chk({ ...okEv, data: core.makeMessage('other', 'changed', {}) }), false, '다른 세션');
assert.equal(chk({ ...okEv, data: core.makeMessage(session, 'init', {}) }), false, '캔버스가 보낼 수 없는 종류');
assert.equal(chk({ ...okEv, data: 'changed' }), false, '문자열');
assert.equal(chk({ ...okEv, data: { ...okEv.data, payload: 'x' } }), false, 'payload 는 객체');
assert.equal(chk(okEv, { session: '' }), false, '세션이 없으면 아무것도 통과하지 않는다');
const toEv = { source: {}, origin: 'http://h', data: core.makeMessage(session, 'insert', { component: 'heading' }) };
assert.equal(core.validateMessage(toEv, { expectedSource: toEv.source, expectedOrigin: 'http://h', session, direction: 'toCanvas' }), true);
});
test('core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다', () => {
const ids = core.COMPONENTS.map((c) => c.id);
for (const need of ['section', 'heading', 'paragraph', 'button', 'image', 'divider', 'spacer']) assert.ok(ids.includes(need), need);
for (const c of core.COMPONENTS) { assert.ok(c.label && /[가-힣]/.test(c.label), '한국어 이름'); const h = core.componentHtml(c.id); assert.ok(h && core.isSafeComponentHtml(h), c.id); assert.ok(!/<script|\son\w+=|javascript:/i.test(h)); }
assert.equal(core.componentHtml('nope'), null); assert.equal(core.isSafeComponentHtml('<p onclick="x()">a</p>'), false); assert.equal(core.isSafeComponentHtml('<a href="javascript:1">a</a>'), false); assert.equal(core.isSafeComponentHtml('<iframe src="x"></iframe>'), false);
});
test('core.moveIndex·dropIndexFor: 재배치 계산', () => {
assert.deepEqual(core.moveIndex(['a', 'b', 'c'], 0, 2), ['b', 'c', 'a']); assert.deepEqual(core.moveIndex(['a', 'b', 'c'], 2, 0), ['c', 'a', 'b']); assert.deepEqual(core.moveIndex(['a', 'b'], 5, 0), ['a', 'b']);
const rects = [{ top: 0, bottom: 100 }, { top: 100, bottom: 200 }, { top: 200, bottom: 300 }];
assert.equal(core.dropIndexFor(rects, 20), 0); assert.equal(core.dropIndexFor(rects, 80), 1); assert.equal(core.dropIndexFor(rects, 160), 2); assert.equal(core.dropIndexFor(rects, 290), 3); assert.equal(core.dropIndexFor([], 10), 0);
});
test('core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙', () => {
assert.equal(core.safeUrlInput('javascript:alert(1)', 'link').ok, false); assert.equal(core.safeUrlInput(' JavaScript:x', 'link').ok, false); assert.equal(core.safeUrlInput('data:text/html,x', 'link').ok, false);
assert.equal(core.safeUrlInput('https://a.b/c', 'link').ok, true); assert.equal(core.safeUrlInput('about.html', 'link').value, 'about.html'); assert.equal(core.safeUrlInput('#top', 'link').ok, true); assert.equal(core.safeUrlInput('mailto:a@b.c', 'link').ok, true); assert.equal(core.safeUrlInput('tel:010', 'link').ok, true);
assert.equal(core.safeUrlInput('data:image/png;base64,AAAA', 'image').ok, true); assert.equal(core.safeUrlInput('data:image/png;base64,AAAA', 'link').ok, false); assert.equal(core.safeUrlInput('data:text/html,<b>', 'image').ok, false);
assert.match(core.safeUrlInput('javascript:1', 'link').error, /[가-힣]/);
assert.ok(core.propsFor('a').includes('href') && core.propsFor('a').includes('text')); assert.ok(core.propsFor('img').includes('src') && core.propsFor('img').includes('alt') && !core.propsFor('img').includes('text'));
for (const k of ['text', 'align', 'color', 'background', 'padding', 'margin']) assert.ok(core.propsFor('p').includes(k), k);
assert.ok(!core.propsFor('hr').includes('text')); assert.ok(core.propsFor('section').includes('background'));
assert.equal(core.labelFor('h1'), '제목 1'); assert.equal(core.labelFor('p'), '문단'); assert.ok(/[가-힣]/.test(core.labelFor('zzz')));
});