818 lines
96 KiB
JavaScript
818 lines
96 KiB
JavaScript
'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: 이름 있는 엔티티(: 	 
 /)·숫자 엔티티(10/16진, 대소문자, 세미콜론 없음)·제어 문자·대소문자 섞임으로 감춘 javascript:/vbscript:/data:text 주소를 모든 주소 속성에서 거절하고, 모르는 엔티티로 흐린 스킴도 거절한다', () => {
|
|||
|
|
const bads = ['javascript:alert(1)', 'javascript:alert(1)', 'JaVa	ScRiPt:alert(1)', '
javascript:alert(1)', 'java
script:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)',
|
|||
|
|
'java\u0000script:alert(1)', ' javascript:alert(1)', 'javascript\t:alert(1)', 'javascript:alert(1)', 'java&bogus;script:alert(1)', 'vbscript:x', 'data:text/html,<b>x</b>', 'data:text/html,x', 'DATA:TEXT/HTML;base64,PGI+', 'javascript:1', 'javascript:1', 'javascript:alert(1)'];
|
|||
|
|
for (const b of bads) assert.equal(ve.isDangerousUrl(b), true, '거절: ' + JSON.stringify(b));
|
|||
|
|
const goods = ['https://a/b?x=1&y=2', 'about.html?x:y', 'mailto:a@b.c', 'tel:+82-10', '#top', 'data:image/png;base64,AAAA', '/path/with&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:alert(1)">x</a><img src="javascript:alert(1)"></body>', OPTS);
|
|||
|
|
assert.ok(!/\shref="javascript:/.test(canvas) && canvas.includes('data-wm-ve-href="javascript:alert(1)"'), canvas); assert.ok(canvas.includes('data-wm-ve-src="javascript: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:alert(1)', 'javascript:alert(1)', 'JaVa	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><script> 와 <script>alert(1)</script> 는 글자</textarea>'), maxBytes: 1e6 }); assert.ok(okBody.html.includes('<textarea><script> 와 <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, '&').replace(/"/g, '"');
|
|||
|
|
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(/"/g, '"').replace(/&/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:alert(1)', 'java:script:alert(1)', 'javascript:alert(1)', 'java\u0000script:alert(1)', '
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&b=2', 'about.html?x: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: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')));
|
|||
|
|
});
|