[verified] feat: add Webmaker visual editor v0.2.0

This commit is contained in:
oz
2026-10-28 15:07:35 +00:00
parent 4b3714e37c
commit 0a56d4612c
40 changed files with 4770 additions and 191 deletions
+55 -2
View File
@@ -7,6 +7,7 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ariweb-')); process.env.DA
fs.mkdirSync(path.join(tmp, 'sites'), { recursive: true }); fs.mkdirSync(path.join(tmp, 'www'), { recursive: true });
const SECRET = 'test-shared-secret-' + crypto.randomBytes(8).toString('hex');
const B = `http://127.0.0.1:${PORT}`;
const SSHWRAP = path.join(tmp, 'sshwrap'); const SSHLOG = path.join(tmp, 'ssh-calls.log');
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
const sign = (key, d) => crypto.createHmac('sha256', key).update(d).digest('base64url');
/** 포털이 하는 서비스 서명 호출 */
@@ -38,12 +39,16 @@ test.before(async () => {
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
const vault = require(ROOT + '/lib/vault'); vault.set({ kind: 'ariweb', id: 'ariweb', label: 'test', url: '', secret: SECRET, settings: {} });
const config = require(ROOT + '/lib/config'); config.set({ 'ariweb.portalUrl': `http://127.0.0.1:${portal.address().port}` }, 'test');
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, SOURCE_PROVIDER: 'none' } });
// 가짜 배포 대상 + 가짜 ssh(PATH 앞): 폐기의 배포 서버 정리를 결정적으로 늦추거나(1초) 실패시킨다(SSH_FAIL). 호출마다 로그를 남겨 "정리가 실제로 수행됐는지" 를 본다
fs.mkdirSync(SSHWRAP, { recursive: true });
fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\necho call >> "${SSHLOG}"\nsleep 1\nif [ -e "${tmp}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 });
fs.writeFileSync(path.join(tmp, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '가짜 배포 서버', type: 'ssh', baseDir: '/srv/sites', publicBaseUrl: '', note: '', manageVhost: false, host: 'fake.test', port: 22, user: 'root', createdAt: new Date().toISOString() }] }));
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, SOURCE_PROVIDER: 'none', PATH: `${SSHWRAP}:${process.env.PATH}` } });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
for (let i = 0; i < 150; i++) { if (/listening on/.test(out)) break; await new Promise((r) => setTimeout(r, 100)); }
assert.match(out, /listening on/);
});
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} });
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} try { fs.chmodSync(tmp, 0o755); } catch (_) {} });
let projectId; let cookie;
test('내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export', async () => {
@@ -91,3 +96,51 @@ test('폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영
const r3 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r3.status, 200, await r3.text());
assert.equal(require(ROOT + '/lib/ariweb').linkOf(projectId), null);
});
/** 포털에 연결된 프로젝트를 만들고 index.html 과 "배포됨" 기록(가짜 배포 대상 t1)을 둔다 → 폐기가 배포 서버 정리(가짜 ssh)를 거친다 */
async function mkLinked(title, siteId) {
const c = await svc('POST', '/api/internal/ariweb/projects', { owner: 'aw-g1-u1', ownerName: '테스트', title, mode: 'manual', link: { siteId, groupId: 'g_1', siteName: '우리 사이트', groupName: '우리 그룹', portalUrl: 'http://127.0.0.1:' + portal.address().port } });
assert.equal(c.status, 200, JSON.stringify(c.b)); const id = c.b.project.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), `<h1>${title}</h1>`);
const f = path.join(tmp, 'sites', `${id}.json`); const rec = JSON.parse(fs.readFileSync(f, 'utf8')); rec.deployments = [{ at: new Date().toISOString(), targetId: 't1', targetName: '가짜 배포 서버', ok: true, ready: true, phase: 'ready', url: 'http://fake.test/x/', domains: [], domainUrls: [] }]; fs.writeFileSync(f, JSON.stringify(rec, null, 2));
return { id, dir, record: f };
}
const sshCalls = () => { try { return fs.readFileSync(SSHLOG, 'utf8').split('\n').filter(Boolean).length; } catch (_) { return 0; } };
const del = (id, title) => fetch(B + `/api/sites/${id}`, { method: 'DELETE', headers: { cookie, 'Content-Type': 'application/json', referer: B + '/' }, body: JSON.stringify({ confirm: title, acknowledgeImpact: true }) });
test('폐기 보상 1 — 배포 서버 정리가 실패하면 포털 연결은 풀리지 않는다(연결 해제는 정리 성공 뒤·되돌릴 수 없는 삭제 직전에만): 사이트 파일·기록·연결이 모두 그대로이고 deleteFailed 가 남는다', async () => {
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const P = await mkLinked('보상-정리실패', 's_c1'); const a = require(ROOT + '/lib/ariweb'); assert.ok(a.linkOf(P.id), '연결됨'); const before = fs.readFileSync(P.record, 'utf8'); const n0 = sshCalls();
fs.writeFileSync(path.join(tmp, 'SSH_FAIL'), '');
const r = await del(P.id, '보상-정리실패'); const j = await r.json().catch(() => ({})); fs.unlinkSync(path.join(tmp, 'SSH_FAIL'));
assert.ok(r.status >= 400, '정리 실패는 오류: ' + r.status + ' ' + JSON.stringify(j)); assert.match(j.error || '', /배포 서버/); assert.ok(sshCalls() > n0, '정리를 실제로 시도했다');
assert.ok(a.linkOf(P.id), '포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): ' + JSON.stringify(a.linkOf(P.id))); assert.equal(a.linkOf(P.id).siteId, 's_c1');
assert.ok(fs.existsSync(path.join(P.dir, 'index.html')), '사이트 파일 그대로'); const after = JSON.parse(fs.readFileSync(P.record, 'utf8')); assert.ok(after.deleteFailed && after.deleteFailed.detail, '실패 기록'); assert.equal(after.title, JSON.parse(before).title);
const ok = await del(P.id, '보상-정리실패'); assert.equal(ok.status, 200, '정리가 되면 폐기 성공: ' + await ok.text()); assert.equal(a.linkOf(P.id), null); assert.ok(!fs.existsSync(P.dir));
});
test('폐기 보상 2 — 정리는 성공했는데 포털 연결 해제(연결 등록부 쓰기)가 실패하면 되돌릴 수 없는 삭제로 가지 않는다: 사이트 파일·기록이 그대로(임시 이름으로 옮긴 것도 되돌림)이고 연결도 그대로, 명시적 실패', async () => {
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const P = await mkLinked('보상-연결해제실패', 's_c2'); const a = require(ROOT + '/lib/ariweb'); const n0 = sshCalls();
fs.chmodSync(tmp, 0o555); // DATA_ROOT 루트를 읽기 전용으로 → 연결 등록부(ariweb-links.json)의 원자적 쓰기(임시 파일)가 실패한다. sites/·www/ 는 하위 폴더라 그대로 쓸 수 있다
let r, j; try { r = await del(P.id, '보상-연결해제실패'); j = await r.json().catch(() => ({})); } finally { fs.chmodSync(tmp, 0o755); }
assert.ok(r.status >= 400, '연결 해제 실패는 오류: ' + r.status + ' ' + JSON.stringify(j)); assert.ok(sshCalls() > n0, '정리는 먼저 수행됐다');
assert.ok(a.linkOf(P.id) && a.linkOf(P.id).siteId === 's_c2', '연결 그대로'); assert.ok(fs.existsSync(path.join(P.dir, 'index.html')), '사이트 파일 그대로(임시 이름 아님)'); assert.ok(fs.existsSync(P.record), '기록 그대로');
assert.ok(!fs.readdirSync(path.join(tmp, 'www')).some((d) => d.startsWith('.deleting-')), '임시로 옮긴 폴더가 남지 않는다');
const g = await fetch(B + `/api/sites/${P.id}`, { headers: { cookie, referer: B + '/' } }); assert.equal(g.status, 200, '사이트는 그대로 쓸 수 있다'); const rec = JSON.parse(fs.readFileSync(P.record, 'utf8')); assert.ok(rec.deleteFailed, '실패 기록');
const ok = await del(P.id, '보상-연결해제실패'); assert.equal(ok.status, 200, '쓸 수 있게 되면 폐기 성공: ' + await ok.text()); assert.equal(a.linkOf(P.id), null);
});
test('폐기 보상 3 — 연결 해제 뒤 되돌릴 수 없는 단계는 기록 삭제 한 번이고, 그 뒤 뒷정리(사이트 폴더 지우기)가 실패해도 성공으로 꾸미거나 삼키지 않는다: 사이트·기록·연결은 모두 없고 남은 폴더는 warnings·감사 기록으로 운영자에게 보인다; 보상(되살리기)은 readback 으로 확인된다', async () => {
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const a = require(ROOT + '/lib/ariweb');
// 보상 함수 자체: 되살리기 뒤 readback 이 원래 연결과 같다(시각 보존)
const l0 = { siteId: 's_x', groupId: 'g_x', portalUrl: 'http://p', siteName: 'n', groupName: 'g', linkedAt: '2026-01-01T00:00:00.000Z' }; a.link('p_comp', l0); a.unlink('p_comp'); assert.equal(a.linkOf('p_comp'), null); a.link('p_comp', l0); assert.deepEqual(a.linkOf('p_comp'), l0, 'readback'); a.unlink('p_comp');
const P = await mkLinked('보상-뒷정리실패', 's_c3');
const locked = path.join(P.dir, 'locked'); fs.mkdirSync(locked); fs.writeFileSync(path.join(locked, 'keep.txt'), 'x'); fs.chmodSync(locked, 0o555); // 안의 파일을 지울 수 없는 폴더 → 옮겨 둔 폴더의 뒷정리(rmSync)가 실패한다
let r, j, trash; try { r = await del(P.id, '보상-뒷정리실패'); j = await r.json().catch(() => ({})); trash = fs.readdirSync(path.join(tmp, 'www')).filter((d) => d.startsWith('.deleting-')); } finally { try { fs.chmodSync(locked, 0o755); } catch (_) {} for (const d of trash || []) { try { fs.chmodSync(path.join(tmp, 'www', d, 'locked'), 0o755); } catch (_) {} } }
assert.equal(r.status, 200, '연결 해제·기록 삭제까지 끝났으면 폐기는 성공: ' + JSON.stringify(j)); assert.equal(j.deleted, P.id); assert.ok(Array.isArray(j.warnings) && j.warnings.some((w) => /폴더 정리 실패/.test(w)), '남은 폴더는 경고로 보인다: ' + JSON.stringify(j.warnings));
assert.equal(a.linkOf(P.id), null, '연결 없음'); assert.ok(!fs.existsSync(P.record), '기록 없음'); assert.ok(!fs.existsSync(P.dir), '원래 이름의 사이트 폴더 없음');
assert.equal(trash.length, 1, '옮겨 둔 폴더가 운영자 확인용으로 남아 있다: ' + JSON.stringify(trash)); assert.ok(fs.existsSync(path.join(tmp, 'www', trash[0], 'locked', 'keep.txt')));
const g = await fetch(B + `/api/sites/${P.id}`, { headers: { cookie, referer: B + '/' } }); assert.equal(g.status, 404);
fs.rmSync(path.join(tmp, 'www', trash[0]), { recursive: true, force: true });
});
@@ -0,0 +1,85 @@
'use strict';
// v1.8.0 검토 수정 — app.js 의 사이트·화면 전환이 직접 편집(시각 편집기)의 저장하지 않은 변경을 묻지 않고 끝내지 않는지, **실제 app.js + visual-editor-ui.js** 를 가짜 DOM 위에서 실행해 확인한다.
// 가짜 DOM: id 로 찾는 요소마다 "무엇을 읽어도 비어 있고 무엇을 불러도 아무 일도 하지 않는" 관대한 요소(classList·value·속성은 실제로 저장). fetch 는 경로별 가짜 응답.
// 검사 경로: openSite(다른 사이트) · showTab('home', manageId 가 다른 사이트) · renderPreview(HTML 페이지가 없어진 갱신) — 취소하면 현재 사이트·편집기·캔버스 유지, 승낙하면 끝낸다.
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 core = require(ROOT + '/public/visual-editor-core');
/** 관대한 가짜 요소: 아는 속성(classList·value·textContent…)은 실제 값, 모르는 속성은 같은 이름이면 같은 객체(식별 비교 가능)를 돌려주는 호출 가능 프록시 */
function omni(name) {
const cls = new Set(); const attrs = {};
const store = { id: name, options: [], children: [], style: {}, dataset: {}, value: '', textContent: '', innerHTML: '', checked: false, disabled: false, hidden: false, files: [], selectionStart: 0, selectionEnd: 0, childElementCount: 0, _ev: {},
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) },
addEventListener(t, fn) { (store._ev[t] || (store._ev[t] = [])).push(fn); }, removeEventListener() {}, querySelectorAll: () => [], getAttribute: (k) => (k in attrs ? attrs[k] : null), setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; }, hasAttribute: (k) => k in attrs,
appendChild: (c) => { store.childElementCount++; return c; }, insertBefore: (c) => c, replaceWith() {}, remove() {}, closest: () => null, contains: () => false, matches: () => false, getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }), focus() {}, click() {}, scrollIntoView() {}, dispatchEvent: () => true, close() {}, showModal() {} };
const fn = function () { return omni(name + '()'); };
return new Proxy(fn, { get(t, k) { if (k === Symbol.toPrimitive) return () => ''; if (typeof k === 'symbol' || k === 'then') return undefined; if (!(k in store)) store[k] = omni(name + '.' + String(k)); return store[k]; }, set(t, k, v) { store[k] = v; return true; }, has: () => true });
}
/** 가짜 MessageChannel(visual-editor.test.js 와 같은 규약: 동기 전달·JSON 복제) */
function fakeChannel() {
const mk = () => ({ onmessage: null, peer: null, closed: 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() {}, close() { this.closed = true; } });
const port1 = mk(); const port2 = mk(); port1.peer = port2; port2.peer = port1; return { port1, port2 };
}
const SITE_A = 'a'.repeat(32); const SITE_B = 'b'.repeat(32); const SESSION = 'ab'.repeat(16);
/** app.js + core + ui 를 한 컨텍스트에서 실행한다. 반환: ctx(전역 함수 openSite·showTab·renderPreview 포함)·els·fetch 기록 */
async function loadApp() {
const els = {}; const $ = (id) => (els[id] || (els[id] = omni(id)));
const document = { body: omni('body'), documentElement: omni('html'), readyState: 'complete', getElementById: $, createElement: (t) => omni(t), createTextNode: () => omni('#text'), querySelectorAll: () => [], querySelector: (s) => omni(s), _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, removeEventListener() {}, activeElement: null, title: '', cookie: '', hidden: false };
const site = (id) => ({ id, title: 'S-' + id[0], files: ['index.html'], url: `http://h/${id}/`, status: 'idle', commands: [], versions: [], totals: {}, attachments: [], git: { seq: 0, changes: [] } });
const ctx = { document, console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout, setInterval, clearInterval, answer: true, asked: 0, confirm() { ctx.asked++; return ctx.answer; }, alert() {}, prompt: () => null,
localStorage: { _: {}, getItem(k) { return k in this._ ? this._[k] : null; }, setItem(k, v) { this._[k] = String(v); }, removeItem(k) { delete this._[k]; } },
location: { origin: 'http://h', href: 'http://h/', search: '', hash: '', pathname: '/' }, history: { replaceState() {} }, navigator: { clipboard: { writeText: async () => {} }, language: 'ko' },
T: (s) => s, TF: (s, ...a) => s.replace(/\{(\d+)\}/g, (_, i) => a[i - 1]), I18N: { lang: 'ko' }, uiLocale: () => 'ko-KR', Option: function (t, v) { this.textContent = t; this.value = v; }, Event: function (t) { this.type = t; }, FormData: function () { this.append = () => {}; }, EventSource: function () { this.close = () => {}; },
URL, URLSearchParams, encodeURIComponent, decodeURIComponent, requestAnimationFrame: (f) => setTimeout(f, 0), matchMedia: () => ({ matches: false, addEventListener() {} }), getComputedStyle: () => ({}),
sites: { [SITE_A]: site(SITE_A), [SITE_B]: site(SITE_B) }, fetchLog: [] };
ctx.routes = (url) => {
if (url.startsWith('/api/auth/csrf')) return { csrf: 't' };
if (url === '/api/config') return { version: '1.8.0', tiers: [], siteLocales: [], user: { id: 'u', name: 'U' }, credit: null, publicBaseUrl: 'http://h' };
if (url === '/api/sites') return []; if (url === '/api/feedback') return { items: [] };
let m = /^\/api\/sites\/([a-f0-9]{32})$/.exec(url); if (m) return ctx.sites[m[1]];
m = /^\/api\/sites\/([a-f0-9]{32})\/visual\/index\.html$/.exec(url); if (m) return { path: 'index.html', hash: 'c'.repeat(64), rev: 1, session: SESSION, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false };
return {};
};
ctx.fetch = async (url, init) => { ctx.fetchLog.push({ url, method: (init && init.method) || 'GET' }); const r = ctx.routes(url, init); return { ok: true, status: 200, json: async () => r, text: async () => JSON.stringify(r), headers: { get: () => null } }; };
ctx.window = ctx; ctx._winEv = {}; ctx.addEventListener = (t, fn) => { (ctx._winEv[t] || (ctx._winEv[t] = [])).push(fn); }; ctx.removeEventListener = () => {}; ctx.fire = (t, ev) => { (ctx._winEv[t] || []).forEach((fn) => fn(ev)); return ev; };
vm.createContext(ctx);
for (const f of ['public/visual-editor-core.js', 'public/app.js', 'public/visual-editor-ui.js']) vm.runInContext(fs.readFileSync(path.join(ROOT, f), 'utf8'), ctx, { filename: f });
await new Promise((r) => setTimeout(r, 30)); // 초기화(설정·사이트 목록) 끝
return { ctx, els, $ };
}
test('app.js 전환 보호: 직접 편집에 저장하지 않은 변경이 있으면 openSite(다른 사이트)·showTab(home, 다른 manageId)·renderPreview(HTML 없는 갱신) 모두 묻고, 취소하면 현재 사이트·편집기·캔버스를 그대로 두며, 승낙하면 끝내고 전환한다', async () => {
const { ctx, els } = await loadApp();
assert.equal(typeof ctx.openSite, 'function'); assert.equal(typeof ctx.showTab, 'function'); assert.equal(typeof ctx.renderPreview, 'function');
await ctx.openSite(SITE_A); assert.equal(els.siteId.textContent, SITE_A, '사이트 A 열림');
const VE = ctx.VisualEditor; assert.ok(VE, 'VisualEditor 로드');
await VE.open('index.html'); assert.equal(VE.isActive(), true); assert.equal(VE.state().siteId, SITE_A);
// 캔버스 연결(ready + 포트) 뒤 변경 → 저장되지 않음
const ch = fakeChannel(); const sent = []; ch.port1.onmessage = (e) => sent.push(e.data);
ctx.fire('message', { data: core.makeMessage(SESSION, 'ready', {}), source: els.frame.contentWindow, origin: 'null', ports: [ch.port2] });
assert.deepEqual(sent.map((m) => m.type), ['init'], '연결');
ch.port1.postMessage(core.makeMessage(SESSION, 'body', { body: '<p>x</p>' })); ch.port1.postMessage(core.makeMessage(SESSION, 'changed', { body: '<p>y</p>' }));
assert.equal(VE.isDirty(), true);
// 1) openSite(B) — 취소: 묻고, 사이트·편집기 유지, B 를 불러오지 않는다
ctx.answer = false; ctx.asked = 0; const f0 = ctx.fetchLog.length;
const r1 = await ctx.openSite(SITE_B);
assert.equal(ctx.asked, 1, '물었다'); assert.equal(VE.isActive(), true); assert.equal(VE.state().siteId, SITE_A); assert.equal(VE.isDirty(), true);
assert.equal(els.siteId.textContent, SITE_A, '현재 사이트 유지'); assert.ok(r1 && r1.id === SITE_A, '지금 사이트를 돌려준다');
assert.ok(!ctx.fetchLog.slice(f0).some((f) => f.url === `/api/sites/${SITE_B}`), '다른 사이트를 불러오지 않는다');
// 2) showTab('home') — manageId 가 B: 취소하면 화면도 사이트도 바꾸지 않는다
vm.runInContext(`manageId = ${JSON.stringify(SITE_B)}`, ctx); ctx.asked = 0; const f1 = ctx.fetchLog.length;
await ctx.showTab('home');
assert.equal(ctx.asked, 1, '홈으로 가기 전에 물었다'); assert.equal(VE.isActive(), true); assert.equal(els.siteId.textContent, SITE_A); assert.ok(!ctx.fetchLog.slice(f1).some((f) => f.url === `/api/sites/${SITE_B}`));
// 3) renderPreview — HTML 페이지가 없어진 갱신: 취소하면 편집기·캔버스(frame) 유지
ctx.asked = 0; ctx.renderPreview({ ...ctx.sites[SITE_A], files: [] }, true);
assert.equal(ctx.asked, 1); assert.equal(VE.isActive(), true); assert.equal(VE.isDirty(), true); assert.equal(els.frame.classList.contains('hidden'), false, '캔버스를 숨기지 않는다'); assert.equal(els.emptyState.classList.contains('hidden'), true);
// 4) 승낙: openSite(B) → 편집기 끝남, B 로 전환
ctx.answer = true; ctx.asked = 0;
await ctx.openSite(SITE_B);
assert.equal(ctx.asked, 1); assert.equal(VE.isActive(), false, '승낙하면 끝낸다'); assert.equal(els.siteId.textContent, SITE_B, 'B 로 전환'); assert.equal(ch.port2.closed, true, '포트 닫힘');
// 5) 변경이 없으면 묻지 않는다: 다시 열고(깨끗) 다른 사이트로
await VE.open('index.html'); assert.equal(VE.state().siteId, SITE_B); ctx.asked = 0; await ctx.openSite(SITE_A); assert.equal(ctx.asked, 0); assert.equal(VE.isActive(), false); assert.equal(els.siteId.textContent, SITE_A);
});
@@ -0,0 +1,605 @@
'use strict';
// v1.8.0 직접 편집(시각 편집기) 서비스 단위 검증 — 빈 데이터로 서버를 띄워 API 로 확인한다.
// 권한(소유자만)·CSRF/출처·HTML 페이지만·경로 탈출·캔버스 문서의 스크립트 비실행 계약·저장(파일 갱신·버전 기록·캐시 미차감·AI 수정 횟수 미계수)·
// 저장 PUT 은 GET 이 준 rev 를 함께 보낸다 — 단독 Webmaker 는 rev 0 을 주고 무시하지만 Ariweb Suite 의 같은 서버 코드는 유한한 숫자 rev 를 필수로 요구한다(이 파일은 두 저장소에서 byte-identical 로 돈다).
// 오래된 저장 거절(409)·AI 작업 중 저장 거절(409, 가짜 claude 실행 파일로 작업을 멈춰 둔 두 번째 서버)·잘못된 본문·화면의 필수 조작을 본다.
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const net = require('net'); const crypto = require('crypto'); const { spawn, execFileSync } = require('child_process');
// 비밀값(설정 토큰·비밀번호)은 실행마다 만들고 변수로만 넘긴다 — 소스에 고정 문자열로 두지 않고 출력하지도 않는다
const SETUP_TOKEN = crypto.randomBytes(24).toString('base64url'); const PW = { alice: crypto.randomBytes(12).toString('base64url'), bob: crypto.randomBytes(12).toString('base64url'), root: crypto.randomBytes(12).toString('base64url') };
const ROOT = path.join(__dirname, '..');
const PAGE = '<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><title>직접 편집</title><style>h1{color:navy}</style><script>window.ran = (window.ran||0)+1</script></head>\n<body><h1 id="h">제목</h1><p class="lead" onclick="track()">문단</p><a href="about.html">소개</a><img src="images/pic.png" alt="사진"><script src="/wm-stats.js"></script></body></html>\n';
/** OS 가 비어 있다고 준 루프백 포트 하나(ephemeral 범위 — 개발 PC 의 다른 서버(예: 브라우저 게이트용 4318)와 겹치지 않는다) */
function freePort() {
return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const { port } = srv.address(); srv.close(() => resolve(port)); }); });
}
/** 서버 한 대를 띄우고 가입·관리자·캐시 지급까지 끝낸 뒤 호출 도우미를 돌려준다. 포트는 OS 가 준 빈 포트를 쓰고, 그 사이 누가 차지해 EADDRINUSE 가 나면 다른 포트로 다시 띄운다(최대 5회) */
async function boot(label, extraEnv = {}, reuseTmp = null, preparedTmp = null) {
const tmp = reuseTmp || preparedTmp || fs.mkdtempSync(path.join(os.tmpdir(), `wm-ve-${label}-`)); const jar = {}; // preparedTmp: 기동 전에 설정 파일(배포 대상 등)을 미리 둔 새 데이터 폴더
if (!reuseTmp) fs.writeFileSync(path.join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 });
let child = null; let out = ''; let PORT = 0; let B = '';
for (let attempt = 0; attempt < 5; attempt++) {
PORT = await freePort(); B = `http://127.0.0.1:${PORT}`; out = '';
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0', ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
const t0 = Date.now();
while (!/listening on/.test(out) && !/EADDRINUSE/.test(out) && child.exitCode === null && Date.now() - t0 < 15000) await new Promise((r) => setTimeout(r, 50));
if (/listening on/.test(out)) break;
try { child.kill('SIGTERM'); } catch (_) {}
if (!/EADDRINUSE/.test(out)) break; // 포트 충돌이 아닌 기동 실패는 다시 시도하지 않고 아래에서 드러낸다
}
assert.match(out, /listening on/, `서버 기동 실패(${label}, 포트 ${PORT}): ${out.slice(-600)}`);
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; if (init.referer !== null) headers.referer = init.referer || `${B}/`; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc && init.keep !== false) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b, headers: r.headers }; };
if (reuseTmp) { // 같은 데이터로 다시 기동(재기동 검증): 계정은 이미 있으므로 로그인만
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice }) }, 'a')).status, 200);
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
} else {
assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) }, 'a')).status, 200);
assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'bob', password: PW.bob, name: 'Bob' }) }, 'b')).status, 200);
assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200);
assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300);
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
assert.equal((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200);
}
/** alice 의 사이트를 만들고 index.html 을 직접 써 둔다(AI 엔진 없이 미리보기 가능한 페이지) */
const mkSite = async (html = PAGE) => { const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE site', purpose: 'p', content: 'c' }) }, 'a'); assert.ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), html); fs.writeFileSync(path.join(dir, 'style.css'), 'body{}'); return { id, dir }; };
return { B, tmp, child, api, mkSite, jar, out: () => out };
}
let S; let S2; let S3; let S4; let S5;
// 기본 서버는 "바로 실패하는" 가짜 claude 를 쓴다 — 개발 PC 에 설치된 진짜 claude 가 사이트 생성 작업으로 실행되지 않게(비용·시간) 하고, 생성 직후 작업이 곧 끝나 저장 검증을 방해하지 않게 한다
const FAKE_FAIL = path.join(os.tmpdir(), `fake-claude-fail-${process.pid}.sh`);
const BARE = path.join(os.tmpdir(), `wm-ve-origin-${process.pid}.git`); // 원격 Git 트랜잭션 검증용 bare 저장소(pre-receive 훅으로 푸시 거부 주입)
const GITWRAP = path.join(os.tmpdir(), `wm-ve-gitwrap-${process.pid}`); // 서버가 쓰는 git 래퍼(푸시 뒤 클라이언트 오류·부분 푸시·불통을 표식 파일로 주입)
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
test.before(async () => { fs.writeFileSync(FAKE_FAIL, '#!/bin/sh\necho "fake claude: not available" >&2\nexit 1\n', { mode: 0o755 }); S = await boot('main', { CLAUDE_BIN: FAKE_FAIL }); });
test.after(() => { for (const s of [S, S2, S3, S4, S5]) { try { s && s.child.kill('SIGTERM'); } catch (_) {} } try { fs.unlinkSync(FAKE_FAIL); } catch (_) {} try { fs.rmSync(BARE, { recursive: true, force: true }); } catch (_) {} try { fs.rmSync(GITWRAP, { recursive: true, force: true }); } catch (_) {} });
/** 사이트 생성이 띄운(실패하는) AI 작업이 끝날 때까지 기다린다 */
async function idle(s, id) { for (let i = 0; i < 100; i++) { const r = await s.api(`/api/sites/${id}`, {}, 'a'); if (!r.b.running) return; await new Promise((r2) => setTimeout(r2, 100)); } throw new Error('AI 작업이 끝나지 않음'); }
test('권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절', async () => {
const { id } = await S.mkSite(); await idle(S, id); global.__ve = { id };
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a')).status, 200, '소유자 GET');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'b')).status, 404, '다른 사용자');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'b')).status, 404);
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'nobody')).status, 401, '로그인 없음');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: null, headers: { 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }, body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 401, 'Referer·CSRF 없는 fetch');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: `${S.B}/${id}/index.html`, body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 401, '생성 사이트 페이지 출처');
});
test('허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절', async () => {
const { id } = global.__ve;
const css = await S.api(`/api/sites/${id}/visual/style.css`, {}, 'a'); assert.equal(css.status, 400); assert.match(css.b.error, /HTML/);
assert.equal((await S.api(`/api/sites/${id}/visual/style.css`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 400);
assert.equal((await S.api(`/api/sites/${id}/visual/..%2F..%2Fetc%2Fpasswd.html`, {}, 'a')).status, 400, '경로 탈출');
assert.equal((await S.api(`/api/sites/${id}/visual/nope.html`, {}, 'a')).status, 404, '없는 파일');
fs.symlinkSync('/etc/hostname', path.join(S.tmp, 'www', id, 'link.html')); assert.equal((await S.api(`/api/sites/${id}/visual/link.html`, {}, 'a')).status, 400, '링크 파일');
});
test('캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다', async () => {
const { id } = global.__ve;
const r = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(r.status, 200);
assert.equal(r.b.path, 'index.html'); assert.match(r.b.hash, /^[a-f0-9]{64}$/); assert.match(r.b.session, /^[a-f0-9]{32}$/); assert.equal(typeof r.b.canvas, 'string');
const c = r.b.canvas;
assert.ok(!/<script>window\.ran/.test(c) && c.includes('<script type="text/x-wm-ve-disabled">window.ran'), '머리 스크립트 비활성화');
assert.ok(c.includes('type="text/x-wm-ve-disabled" src="/wm-stats.js"') || /<script type="text\/x-wm-ve-disabled"[^>]*src="\/wm-stats\.js"/.test(c), '외부 스크립트 비활성화');
assert.ok(!/\sonclick=/.test(c) && c.includes('data-wm-ve-on-click="track()"'));
assert.ok(c.includes(`<base href="${S.B}/${id}/"`), '상대 경로 자원이 사이트 주소 기준으로 풀린다');
assert.match(c, /Content-Security-Policy" content="[^"]*script-src 'nonce-/); assert.ok(c.includes('WMVisualCore') && c.includes(`"session":"${r.b.session}"`) && c.includes(`"parentOrigin":"${S.B}"`), '런타임·세션·부모 출처');
assert.ok(!c.includes('wh_session') && !c.includes(SETUP_TOKEN), '세션 쿠키·토큰 같은 비밀값 없음');
const f = await S.api(`/api/sites/${id}/files/index.html`, {}, 'a'); assert.equal(f.status, 200); assert.equal(f.b.hash, r.b.hash, 'GET /files 의 hash = 캔버스 hash');
assert.equal(r.headers.get('cache-control'), 'no-store');
});
test('저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존', async () => {
const { id } = global.__ve;
const before = await S.api(`/api/sites/${id}`, {}, 'a'); const credit0 = before.b.credit.balance; const cmds0 = (before.b.commands || []).length; const ver0 = (before.b.versions || []).length;
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const body = '<h1 id="h" contenteditable="true" data-wm-ve-editable="">새 제목</h1><div data-wm-ve-ui=""><button>툴바</button></div><p class="lead" data-wm-ve-on-click="track()">문단</p><section class="wm-section"><h2>새 섹션</h2></section><a href="about.html">소개</a><img src="images/pic.png" alt="사진"><script type="text/x-wm-ve-disabled" src="/wm-stats.js"></script>';
const put = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body, baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(put.status, 200, JSON.stringify(put.b)); assert.equal(put.b.path, 'index.html'); assert.match(put.b.hash, /^[a-f0-9]{64}$/); assert.notEqual(put.b.hash, g.b.hash); assert.ok(Array.isArray(put.b.files) && put.b.files.includes('index.html'));
const saved = fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8');
assert.ok(saved.startsWith('<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><title>직접 편집</title><style>h1{color:navy}</style><script>window.ran = (window.ran||0)+1</script></head>\n<body>'), '머리·스크립트 보존: ' + saved.slice(0, 120));
assert.ok(saved.includes('<h1 id="h">새 제목</h1><p class="lead" onclick="track()">문단</p><section class="wm-section"><h2>새 섹션</h2></section>'), saved);
assert.ok(saved.includes('<script src="/wm-stats.js"></script></body></html>'), '본문 끝 스크립트 복원'); assert.ok(!/data-wm-ve|contenteditable|툴바/.test(saved), '표식·덧씌운 요소 없음');
const after = await S.api(`/api/sites/${id}`, {}, 'a');
assert.equal(after.b.credit.balance, credit0, '캐시 미차감'); assert.equal((after.b.commands || []).length, cmds0, 'AI 명령 기록에 추가되지 않음');
assert.equal((after.b.versions || []).length, ver0 + 1, '버전 1개 추가'); const last = after.b.versions[after.b.versions.length - 1]; assert.equal(last.kind, 'visual-edit'); assert.match(last.message, /직접 편집.*index\.html/); assert.ok(last.commit);
assert.ok((after.b.manualEdits || []).some((m) => m.path === 'index.html' && m.kind === 'visual'), '직접 편집 기록');
assert.ok(put.b.change && put.b.change.kind === 'visual-edit' && put.b.git && put.b.versions, '응답에 버전 정보');
assert.equal((await S.api(`/api/sites/${id}/files/index.html`, {}, 'a')).b.hash, put.b.hash, '소스 편집기용 hash 와 같다');
global.__ve.hash = put.b.hash;
});
test('오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400', async () => {
const { id, hash } = global.__ve;
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const stale = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>다른 창</p>', baseHash: 'd'.repeat(64), rev: g.b.rev }) }, 'a');
assert.equal(stale.status, 409); assert.match(stale.b.error, /먼저 저장|달라졌습니다/); assert.equal(stale.b.currentHash, hash);
assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('다른 창'), false, '파일은 그대로');
const nohash = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>' }) }, 'a'); assert.equal(nohash.status, 400); assert.match(nohash.b.error, /baseHash/);
const bad = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: 5, baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(bad.status, 400); assert.match(bad.b.error, /문자열/);
const js = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p><script>alert(1)</script>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(js.status, 400); assert.match(js.b.error, /스크립트/);
const ev = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p onload="x()">x</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ev.status, 400); assert.match(ev.b.error, /이벤트 속성/);
const url = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<a href="javascript:alert(1)">x</a>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(url.status, 400); assert.match(url.b.error, /허용되지 않는/);
const big = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>' + 'x'.repeat(5 * 1024 * 1024 + 100) + '</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(big.status, 413);
const nojson = await fetch(`${S.B}/api/sites/${id}/visual/index.html`, { method: 'PUT', headers: { 'Content-Type': 'application/json', referer: `${S.B}/`, cookie: S.jar.a }, body: '{not json' }); assert.ok(nojson.status >= 400 && nojson.status < 500, 'JSON 파싱 실패는 4xx: ' + nojson.status);
assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('새 제목'), true, '거절된 요청은 파일을 바꾸지 않음');
});
/**
* 머리 + 본문 앞부분만 보내 두고(chunked) 끝내지 않는 요청 → 서버 처리기는 라우트에 들어가 본문 읽기(await)에서 멈춘다. finish() 가 나머지를 보낸다.
* 잠금·동시성 검증용: "먼저 들어왔지만 나중에 본문을 끝낸" 요청이 그 사이의 다른 변경을 덮어쓰지 않는지 본다.
*/
function slowRequest(s, method, urlPath, payload, who = 'a') {
const json = JSON.stringify(payload); let resolve; const done = new Promise((r) => { resolve = r; });
const req = http.request(`${s.B}${urlPath}`, { method, headers: { 'Content-Type': 'application/json', referer: `${s.B}/`, cookie: s.jar[who], 'Transfer-Encoding': 'chunked' } }, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { let b = {}; try { b = JSON.parse(d); } catch (_) {} resolve({ status: res.statusCode, b }); }); });
req.on('error', (e) => resolve({ status: 0, b: { error: e.message } }));
req.write(json.slice(0, 12)); req.flushHeaders();
return { done, finish: () => req.end(json.slice(12)) };
}
/** 뒤처진 저장의 409 검사. 단독 Webmaker 는 파일 hash 불일치(stale + currentHash), Ariweb Suite 의 같은 코드는 잠금 안에서 rev 충돌(conflict)을 먼저 보므로 둘 중 하나면 된다 */
function expectStale(r, hash, label) { assert.equal(r.status, 409, (label || '오래된 저장') + ': ' + JSON.stringify(r.b)); assert.ok(r.b.stale === true || (r.b.conflict && typeof r.b.conflict.currentRev === 'number'), (label || '') + ' stale 또는 rev 충돌: ' + JSON.stringify(r.b)); if (r.b.stale) assert.equal(r.b.currentHash, hash, (label || '') + ' 최신 hash 는 먼저 끝난 저장의 결과'); }
/** 가짜 claude 가 남긴 sleep 자식 정리(서버를 죽여도 남는다) */
function pkillSleep() { try { execFileSync('pkill', ['-f', '^sleep 30$']); } catch (_) {} }
/** 디렉터리 트리의 모드를 바꾼다(실패 주입: 읽기 전용으로 만들어 저장소·git 쓰기를 실패시킨다) */
function chmodTree(dir, mode) { for (const f of fs.readdirSync(dir, { withFileTypes: true })) if (f.isDirectory()) chmodTree(path.join(dir, f.name), mode); fs.chmodSync(dir, mode); }
test('동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다', async () => {
const { id } = await S.mkSite(); await idle(S, id);
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const index = path.join(S.tmp, 'www', id, 'index.html');
const start = (marker) => slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: `<h1 id="h">${marker}</h1>`, baseHash: g.b.hash, rev: g.b.rev });
const A = start('첫째-A'); const B = start('둘째-B');
await new Promise((r) => setTimeout(r, 400)); // 두 처리기가 모두 라우트에 들어가 본문을 기다리는 상태를 만든다
A.finish(); const ra = await A.done; assert.equal(ra.status, 200, JSON.stringify(ra.b));
assert.ok(fs.readFileSync(index, 'utf8').includes('첫째-A'), '첫 저장 반영');
B.finish(); const rb = await B.done;
expectStale(rb, ra.b.hash, '둘째는 오래된 저장');
const final = fs.readFileSync(index, 'utf8'); assert.ok(final.includes('첫째-A') && !final.includes('둘째-B'), '첫 저장이 살아 있다: ' + final.slice(0, 200));
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 1, '버전도 한 번만');
});
test('사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT 이 본문을 보내는 동안 함께 들어와도 잠금 안에서 다시 검사해 뒤의 저장은 409(stale), 파일은 먼저 저장한 쪽 그대로 — 양방향; baseHash 없는 옛 소스 저장은 예전처럼 동작', async () => {
const { id } = await S.mkSite(); await idle(S, id); const index = path.join(S.tmp, 'www', id, 'index.html');
const v0 = ((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions || []).length; // 사이트 생성이 남긴 버전(create)은 이 테스트의 저장이 아니다
// 1) 직접 편집이 먼저 끝남 → 소스 편집기 저장은 오래된 baseHash 로 409
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
let V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">시각-1</h1>', baseHash: g.b.hash, rev: g.b.rev });
let T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-1'), baseHash: g.b.hash, rev: g.b.rev });
await new Promise((r) => setTimeout(r, 400));
V.finish(); const rv = await V.done; assert.equal(rv.status, 200, JSON.stringify(rv.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('시각-1'));
T.finish(); const rt = await T.done; expectStale(rt, rv.b.hash, '소스 저장은 오래됨');
let cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('시각-1') && !cur.includes('소스-1'), '직접 편집 저장이 살아 있다');
// 2) 소스 편집기가 먼저 끝남 → 직접 편집 저장은 409 stale
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-2'), baseHash: g.b.hash, rev: g.b.rev });
V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">시각-2</h1>', baseHash: g.b.hash, rev: g.b.rev });
await new Promise((r) => setTimeout(r, 400));
T.finish(); const rt2 = await T.done; assert.equal(rt2.status, 200, JSON.stringify(rt2.b)); assert.match(rt2.b.hash || '', /^[a-f0-9]{64}$/, '소스 저장 응답에도 hash');
V.finish(); const rv2 = await V.done; expectStale(rv2, rt2.b.hash, '직접 편집 저장은 오래됨');
cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('소스-2') && !cur.includes('시각-2'));
// 3) baseHash 가 틀린 소스 저장은 바로 409, baseHash 없는 옛 화면의 소스 저장은 200(호환)
const bad = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE, baseHash: 'e'.repeat(64) }) }, 'a'); assert.equal(bad.status, 409); assert.equal(bad.b.stale, true);
const old = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '옛 화면') }) }, 'a'); assert.equal(old.status, 200); assert.ok(fs.readFileSync(index, 'utf8').includes('옛 화면'));
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.deepEqual((s.b.versions || []).slice(v0).map((v) => v.kind), ['visual-edit', 'manual-edit', 'manual-edit'], '이 테스트에서 성공한 저장만 버전으로(409 두 건은 버전 없음)');
});
test('직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 페이지를 원본으로 되돌리고 500 — 버전·manualEdits·임시 파일 없음, 같은 baseHash 로 다시 저장하면 성공', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const sitesDir = path.join(S.tmp, 'sites');
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a'); const refs0 = localRefs(S, id);
fs.chmodSync(sitesDir, 0o555); // 사이트 기록 폴더에 임시 파일을 만들 수 없음 → store.save 실패(로컬 커밋은 이미 만들어진 뒤 → 되감아야 한다)
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); }
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다|되돌렸습니다/); assert.match(r.b.error, /그대로|되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b));
assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 원본으로 되돌아간다'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 없음');
assert.deepEqual(localRefs(S, id), refs0, '로컬 커밋·변경 브랜치를 되감아 브랜치가 저장 전 그대로'); let indexClean = true; try { siteGit(S, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, 'add 로 올라간 인덱스를 비운다');
const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '버전 추가 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 추가 없음');
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '같은 baseHash 로 재시도 성공: ' + JSON.stringify(ok.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장'));
assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 1);
});
test('직접 편집 저장 트랜잭션: Git 버전 기록이 실패하면 200 으로 꾸미지 않고 페이지·사이트 기록을 저장 전으로 되돌려 500 — 거짓 visual-edit 버전 없음, 같은 baseHash 로 다시 저장하면 성공', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html');
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const first = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">첫 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(first.status, 200, JSON.stringify(first.b)); assert.ok(first.b.change && first.b.change.commit, '첫 저장은 git 기록');
const gitDir = path.join(S.tmp, 'git', id); assert.ok(fs.existsSync(path.join(gitDir, 'HEAD')), '사이트 git 저장소'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a');
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
chmodTree(gitDir, 0o555); // git 이 index.lock·객체를 쓸 수 없음 → 커밋 실패
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">git 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { chmodTree(gitDir, 0o755); }
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /버전|Git|git/); assert.match(r.b.error, /되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b));
assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 저장 전으로'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)));
assert.equal(siteGit(S, id, ['status', '--porcelain']), '', '인덱스·작업 트리에 남은 변경 없음'); assert.equal(siteGit(S, id, ['rev-parse', 'HEAD']), first.b.change.commit, 'HEAD 는 첫 저장 커밋 그대로'); assert.ok(!Object.keys(localRefs(S, id)).some((r2) => /visual-edit$/.test(r2) && !r2.endsWith(first.b.change.branch)), '실패한 저장의 변경 브랜치 없음: ' + JSON.stringify(localRefs(S, id)));
const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '거짓 버전 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 되돌림'); assert.equal(s1.b.versions.filter((v) => v.kind === 'visual-edit').length, 1);
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 baseHash 로 저장: ' + JSON.stringify(ok.b)); assert.ok(ok.b.change && ok.b.change.kind === 'visual-edit');
assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 2);
});
/** 사이트 git 저장소에 명령을 보낸다(검증용 읽기·훅 설치) */
function siteGit(s, id, args) { return execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(s.tmp, 'git', id), GIT_WORK_TREE: path.join(s.tmp, 'www', id) }, encoding: 'utf8' }).trim(); }
/** 원격(bare) 저장소의 모든 ref → { refname: sha } */
function remoteRefs() { const out = execFileSync('git', ['--git-dir', BARE, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim(); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); }
/** 로컬 사이트 저장소의 모든 브랜치 → { refname: sha } */
function localRefs(s, id) { const out = siteGit(s, id, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/']); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); }
/** 되돌리기 검증: 페이지·HEAD·인덱스(HEAD 와 같음)·작업 트리(변경 없음)·로컬 브랜치·원격 ref·버전 목록·manualEdits 가 모두 저장 전 그대로 */
function assertRolledBack(s, id, snap, r, label) {
assert.equal(r.status, 500, label + ': ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, label + ': rolledBack 은 검증된 뒤에만 true: ' + JSON.stringify(r.b)); assert.match(r.b.error, /되돌렸습니다/, label);
assert.equal(fs.readFileSync(snap.index, 'utf8'), snap.page, label + ': 페이지 원본'); assert.ok(!fs.readdirSync(path.dirname(snap.index)).some((f) => /\.tmp$/.test(f)), label + ': 임시 파일 없음');
assert.equal(siteGit(s, id, ['rev-parse', '--verify', '-q', 'HEAD']), snap.head, label + ': HEAD 그대로');
let indexClean = true; try { siteGit(s, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, label + ': 인덱스가 HEAD 와 같다(add 로 올라간 것 없음)');
assert.equal(siteGit(s, id, ['status', '--porcelain']), '', label + ': 작업 트리 변경 없음');
assert.deepEqual(localRefs(s, id), snap.local, label + ': 로컬 브랜치(변경 브랜치 없음)'); assert.deepEqual(remoteRefs(), snap.remote, label + ': 원격 ref 그대로');
}
test('직접 편집 저장 트랜잭션(원격 Git, 진짜 bare 저장소): 성공하면 페이지·사이트 기록·로컬 커밋 뒤 main 과 변경 브랜치가 한 번의 원자적 푸시로 함께 바뀌고, 푸시 거부·커밋 실패(add 뒤)·사이트 기록 실패 때는 원격 두 ref 모두 그대로이며 로컬 HEAD·인덱스·작업 트리·변경 브랜치·페이지·기록이 검증된 채 되돌아가 500(rolledBack) — 같은 baseHash 로 재시도하면 성공', async () => {
execFileSync('git', ['init', '-q', '--bare', BARE]);
fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
// git 래퍼(서버의 PATH 앞): 표식 파일로 "원격이 받았는데 클라이언트가 오류"(FAIL_AFTER)·"브랜치만 올라가고 실패"(MIXED)·"받은 뒤 오류 + 그 뒤 ls-remote 불통"(FLAKY)·"완전 불통"(DOWN) 을 결정적으로 만든다
fs.mkdirSync(GITWRAP, { recursive: true });
fs.writeFileSync(path.join(GITWRAP, 'git'), `#!/bin/sh\nF="${BARE}"\nif [ -e "$F/SLOW_COMMIT" ]; then for a in "$@"; do if [ "$a" = "commit" ]; then sleep 2; break; fi; done; fi\nif [ "$1" = "push" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FAIL_AFTER" ] || [ -e "$F/FLAKY" ]; then "${REAL_GIT}" "$@"; touch "$F/PUSHED_ONCE"; echo "fatal: the remote end hung up unexpectedly (test)" >&2; exit 128; fi\n if [ -e "$F/MIXED" ]; then SPEC=""; for a in "$@"; do case "$a" in *changes/*) SPEC="$a";; esac; done; "${REAL_GIT}" push -q origin "$SPEC"; echo "fatal: partial push (test)" >&2; exit 128; fi\nfi\nif [ "$1" = "ls-remote" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FLAKY" ] && [ -e "$F/PUSHED_ONCE" ]; then echo "fatal: unable to access origin: timeout (test)" >&2; exit 128; fi\nfi\nexec "${REAL_GIT}" "$@"\n`, { mode: 0o755 });
S3 = await boot('remote', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` });
const { id, dir } = await S3.mkSite(); await idle(S3, id); const index = path.join(dir, 'index.html'); const gitDir = path.join(S3.tmp, 'git', id);
const MAIN = `refs/heads/sites/${id}/main`; const branchRef = (rec) => `refs/heads/${rec.branch}`;
// 훅: 푸시가 원격에 닿은 **순간** 의 사이트 기록(푸시 전에 저장된 것)을 복사해 둔다 — 그 기록이 "원격 반영됨" 을 주장하면 안 된다
const record = path.join(S3.tmp, 'sites', `${id}.json`); const captured = path.join(BARE, 'during-push.json');
fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\ncp "${record}" "${captured}" 2>/dev/null\necho push >> "${BARE}/pushes.log"\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
const lastVisual = (site) => (site.git.changes || []).filter((c) => c.kind === 'visual-edit').slice(-1)[0];
const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const s = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('원격 확인(verified) 이 되지 않음'); };
// 0) 성공: 푸시 전에 저장된 기록은 pushed:false·remote:'pending'(원격 반영을 주장하지 않는다), 응답도 같다; 원격 main·변경 브랜치가 같은 커밋을 가리키고, 조정(reconcile)이 ls-remote 로 확인한 뒤에야 pushed:true·remote:'verified' 가 된다
const n0 = Object.keys(remoteRefs()).length;
let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const ok0 = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">원격 첫 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(ok0.status, 200, JSON.stringify(ok0.b)); assert.ok(ok0.b.change && ok0.b.change.commit, JSON.stringify(ok0.b.change));
assert.ok(fs.existsSync(captured), '훅이 푸시 순간의 기록을 복사했다'); const during = lastVisual(JSON.parse(fs.readFileSync(captured, 'utf8')));
assert.ok(during && during.commit === ok0.b.change.commit, '푸시 전에 로컬 커밋·기록이 저장되어 있다: ' + JSON.stringify(during));
assert.equal(during.pushed, false, '푸시 전 기록은 원격 반영을 주장하지 않는다: ' + JSON.stringify(during)); assert.equal(during.remote, 'pending', '푸시 전 기록은 pending: ' + JSON.stringify(during));
assert.equal(ok0.b.change.pushed, false, '응답도 원격 ref 로 확인하기 전에는 pushed 를 주장하지 않는다: ' + JSON.stringify(ok0.b.change)); assert.equal(ok0.b.change.remote, 'pending');
let rr = remoteRefs(); assert.equal(rr[MAIN], ok0.b.change.commit, '원격 main'); assert.equal(rr[branchRef(ok0.b.change)], ok0.b.change.commit, '원격 변경 브랜치'); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 1, '새 변경 브랜치 하나(+ 처음이면 main): ' + JSON.stringify(rr));
const v0 = await waitVerified(ok0.b.change.commit); assert.equal(v0.error, null); assert.ok(v0.verifiedAt, '확인 시각');
const pushes0 = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length; assert.equal(pushes0, 1, '저장 뒤 조정은 ls-remote 로 확인만 하고 다시 푸시하지 않는다(기록 훅 설치 뒤 푸시 1회): ' + pushes0);
assert.equal(siteGit(S3, id, ['status', '--porcelain']), '', '성공 뒤 작업 트리 깨끗');
const snapshot = async () => ({ index, page: fs.readFileSync(index, 'utf8'), head: siteGit(S3, id, ['rev-parse', '--verify', '-q', 'HEAD']), local: localRefs(S3, id), remote: remoteRefs(), site: (await S3.api(`/api/sites/${id}`, {}, 'a')).b });
const assertSiteSame = (snap, after, label) => { assert.equal((after.versions || []).length, (snap.site.versions || []).length, label + ': 거짓 버전 없음'); assert.equal((after.manualEdits || []).length, (snap.site.manualEdits || []).length, label + ': manualEdits 되돌림'); assert.equal((after.git && after.git.seq) || 0, (snap.site.git && snap.site.git.seq) || 0, label + ': 변경 번호 그대로'); };
// 1) 원격이 푸시를 거부(pre-receive) → 500, 원격 두 ref 모두 그대로, 로컬 커밋·변경 브랜치·인덱스·페이지·기록 되돌림
fs.writeFileSync(path.join(BARE, 'REJECT'), '');
g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let snap = await snapshot();
let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">푸시 거부</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assertRolledBack(S3, id, snap, r, '푸시 거부'); assert.match(r.b.error, /원격|푸시|Git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '푸시 거부');
fs.unlinkSync(path.join(BARE, 'REJECT'));
let retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">푸시 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(retry.status, 200, '같은 baseHash 로 재시도: ' + JSON.stringify(retry.b)); assert.equal(retry.b.change.remote, 'pending'); await waitVerified(retry.b.change.commit); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 2, '변경 브랜치는 성공한 저장마다 하나(실패한 저장의 브랜치 없음): ' + JSON.stringify(rr));
assert.equal(retry.b.change.no, ok0.b.change.no + 1, '실패한 저장은 변경 번호를 쓰지 않는다');
// 2) add 는 되는데 커밋이 실패(pre-commit 훅) → 인덱스도 비워야 한다, 원격·HEAD·브랜치 그대로
fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true }); fs.writeFileSync(path.join(gitDir, 'hooks', 'pre-commit'), '#!/bin/sh\necho "commit rejected by test hook" >&2\nexit 1\n', { mode: 0o755 });
g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot();
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">커밋 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
fs.unlinkSync(path.join(gitDir, 'hooks', 'pre-commit'));
assertRolledBack(S3, id, snap, r, '커밋 실패'); assert.match(r.b.error, /버전|Git|git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '커밋 실패');
retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">커밋 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); assert.equal(remoteRefs()[MAIN], retry.b.change.commit);
// 3) 로컬 커밋은 되는데 사이트 기록(store) 저장이 실패 → 원격에 아무것도 올리지 않고 로컬 커밋·변경 브랜치를 되감는다
const sitesDir = path.join(S3.tmp, 'sites'); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot();
fs.chmodSync(sitesDir, 0o555);
try { r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); }
assertRolledBack(S3, id, snap, r, '기록 실패'); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '기록 실패');
retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit);
assert.ok(fs.readFileSync(index, 'utf8').includes('기록 재시도'));
await waitVerified(retry.b.change.commit);
const final = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; assert.deepEqual(final.git.changes.filter((c) => c.kind === 'visual-edit').map((c) => [c.pushed, c.remote, c.error]), [[true, 'verified', null], [true, 'verified', null], [true, 'verified', null], [true, 'verified', null]], '성공한 저장 4건만 기록되고 모두 원격 ref 로 확인됨: ' + JSON.stringify(final.git.changes.map((c) => [c.no, c.kind, c.pushed, c.remote])));
global.__remote = { id, record, commit: retry.b.change.commit, branch: retry.b.change.branch };
});
test('원자적 푸시의 모호한 클라이언트 오류: 원격이 받은 뒤 오류면 main·변경 브랜치를 ls-remote 로 확인해 성공으로(되돌리지 않음·pending → verified), 브랜치만 올라간 혼합 상태·확인 불가(불통)면 로컬 커밋·페이지·기록을 pending 으로 남기고 remoteState:ambiguous 를 돌려주며 rolledBack 을 주장하지 않는다(뒤에 조정이 해결), 아예 닿지 않으면(푸시 전 확인 실패) 원격이 바뀌지 않았음을 알고 되돌린다', async () => {
const { id, record } = global.__remote; const index = path.join(S3.tmp, 'www', id, 'index.html'); const MAIN = `refs/heads/sites/${id}/main`;
const flags = ['DOWN', 'FAIL_AFTER', 'FLAKY', 'MIXED', 'PUSHED_ONCE']; const clear = () => { for (const f of flags) try { fs.unlinkSync(path.join(BARE, f)); } catch (_) {} };
const site = async () => (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const pushes = () => fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length;
const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const c = ((await site()).git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('verified 되지 않음'); };
const settle = () => new Promise((r) => setTimeout(r, 400)); // 응답 뒤 setImmediate 조정이 끝날 시간
try {
// 1) 원격이 받았는데 클라이언트가 오류: 두 ref 가 의도한 커밋 → 성공으로 본다(되돌리지 않는다), 기록은 pending → 조정 뒤 verified
fs.writeFileSync(path.join(BARE, 'FAIL_AFTER'), ''); let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let before = fs.readFileSync(index, 'utf8');
let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">받은 뒤 오류</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 200, '원격이 받았으면 성공: ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, undefined); assert.equal(r.b.change.remote, 'pending'); assert.ok(fs.readFileSync(index, 'utf8').includes('받은 뒤 오류'));
let rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit);
clear(); await waitVerified(r.b.change.commit);
// 2) 브랜치만 올라가고 main 은 아님(혼합): 되돌리지 않고 pending + remoteState:ambiguous, 페이지·로컬 커밋·기록 유지; 조정(다시 푸시)으로 해결
fs.writeFileSync(path.join(BARE, 'MIXED'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const mainBefore = remoteRefs()[MAIN];
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">혼합 상태</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 200, '로컬 저장은 성공·원격은 모호: ' + JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.remote, 'pending'); assert.equal(r.b.change.pushed, false); assert.match(r.b.change.error || '', /모호|혼합|확인/);
assert.ok(fs.readFileSync(index, 'utf8').includes('혼합 상태'), '페이지 유지'); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), r.b.change.commit, '로컬 커밋 유지');
rr = remoteRefs(); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit, '브랜치는 올라감'); assert.equal(rr[MAIN], mainBefore, 'main 은 그대로');
await settle(); let c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === false && c.remote === 'pending', '읽기 결과도 pending(거짓 성공 없음): ' + JSON.stringify(c));
clear(); const retry = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b));
c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', '조정 뒤 verified: ' + JSON.stringify(c)); assert.equal(remoteRefs()[MAIN], r.b.change.commit, 'main 도 따라옴');
// 3) 받은 뒤 오류 + 그 뒤 원격 확인 불가(불통): 확인할 수 없으니 pending + ambiguous, 되돌리지 않음; 불통이 풀리면 확인만으로 verified(추가 푸시 없음)
fs.writeFileSync(path.join(BARE, 'FLAKY'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">확인 불가</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 200, JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.pushed, false);
rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit, '실제로는 원격이 받았다'); await settle(); const n = pushes();
clear(); const retry2 = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry2.status, 200);
c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', JSON.stringify(c)); assert.equal(pushes(), n, '이미 올라가 있으니 확인만(추가 푸시 없음)');
// 4) 완전 불통(푸시 전 원격 확인부터 실패): 푸시를 시도하지 않았으므로 원격이 바뀌지 않았음을 알고 되돌린다 — 500 rolledBack:true
fs.writeFileSync(path.join(BARE, 'DOWN'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); before = fs.readFileSync(index, 'utf8'); const refs0 = remoteRefs(); const head0 = siteGit(S3, id, ['rev-parse', 'HEAD']); const s0 = await site();
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">불통</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, JSON.stringify(r.b)); assert.equal(fs.readFileSync(index, 'utf8'), before); assert.deepEqual(remoteRefs(), refs0); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), head0); assert.equal((await site()).git.changes.length, s0.git.changes.length);
clear(); const ok = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">불통 뒤 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, JSON.stringify(ok.b)); await waitVerified(ok.b.change.commit);
global.__remote = { ...global.__remote, commit: ok.b.change.commit, branch: ok.b.change.branch };
void record;
} finally { clear(); }
});
test('원격 반영 조정(기동 때): 기록 저장과 푸시 사이에 프로세스가 죽은 상태(기록은 pending, 로컬 커밋·변경 브랜치는 있고 원격에는 없음)를 만들어 서버를 같은 데이터로 다시 띄우면, 기동 조정이 로컬 커밋·브랜치를 찾아 main + 변경 브랜치를 원자적으로 올리고 원격 ref 로 확인한 뒤에야 pushed:true·verified 로 바꾼다', async () => {
const { id, record, commit, branch } = global.__remote;
S3.child.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 300));
// 크래시 창 재현: 원격의 변경 브랜치를 지우고 main 을 한 커밋 전으로 되돌린 뒤, 기록을 푸시 전 상태(pending)로 되돌린다
const site = JSON.parse(fs.readFileSync(record, 'utf8')); const rec = site.git.changes.find((c) => c.commit === commit); assert.ok(rec);
rec.pushed = false; rec.remote = 'pending'; rec.error = null; delete rec.verifiedAt; fs.writeFileSync(record, JSON.stringify(site, null, 2));
execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${branch}`]); execFileSync('git', ['--git-dir', BARE, 'update-ref', `refs/heads/sites/${id}/main`, `${commit}~1`]);
const before = remoteRefs(); assert.equal(before[`refs/heads/${branch}`], undefined); assert.notEqual(before[`refs/heads/sites/${id}/main`], commit);
const pushesBefore = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length;
S4 = await boot('restart', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea' }, S3.tmp);
let c = null; for (let i = 0; i < 80; i++) { const s = (await S4.api(`/api/sites/${id}`, {}, 'a')).b; c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true) break; await new Promise((r) => setTimeout(r, 100)); }
assert.ok(c && c.pushed === true && c.remote === 'verified' && c.error === null, '기동 조정 뒤 원격 ref 로 확인된 기록: ' + JSON.stringify(c));
const after = remoteRefs(); assert.equal(after[`refs/heads/${branch}`], commit, '변경 브랜치가 원격에 올라갔다: ' + JSON.stringify(after)); assert.equal(after[`refs/heads/sites/${id}/main`], commit, 'main 도 함께: ' + JSON.stringify({ after, head: siteGit(S4, id, ['rev-parse', 'HEAD']), c }));
assert.equal(fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, pushesBefore + 1, '원자적 푸시 한 번');
const saved = JSON.parse(fs.readFileSync(record, 'utf8')); assert.equal(saved.git.changes.find((x) => x.commit === commit).remote, 'verified', '기록도 저장됨'); assert.equal(saved.rev, site.rev, '조정은 내용 변경이 아니므로 rev 를 올리지 않는다(Suite)');
});
test('store.save 합치기 규칙(저장소 불변식): 읽은 뒤 바뀐 항목만 디스크 최신 기록 위에 합치되 중첩 객체는 잎 단위로 보고, 같은 잎(배열 포함)을 둘 다 다르게 바꾼 진짜 동시 변경은 site_save_conflict 로 거절해 디스크의 먼저 쓴 값을 보존한다(같은 값이면 충돌 아님) — update() 는 최신 기록을 다시 읽어 그 위에 적용하는 안전 경로', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-store-')); const id = 'a'.repeat(32);
const script = `
const store = require(${JSON.stringify(path.join(ROOT, 'lib', 'store.js'))}); const id = ${JSON.stringify(id)}; const out = {};
const tryCatch = (fn) => { try { return fn(); } catch (e) { return { code: e.code, status: e.status, fields: e.fields, message: e.message }; } };
store.save({ id, title: 'T0', tier: 'basic', plan: { brief: { assets: { logo: null, photos: [] } }, verification: null, stage: 'generated' }, attachments: [], git: { seq: 1, changes: [{ no: 1, pushed: false }] }, commands: [] });
let A = store.load(id), B = store.load(id); A.title = 'A-title'; store.save(A); B.tier = 'standard'; store.save(B);
out.r1 = { obj: [B.title, B.tier], disk: [store.load(id).title, store.load(id).tier] };
A = store.load(id); B = store.load(id); A.plan.brief.assets.logo = 'logo.png'; store.save(A); B.plan.verification = { at: 'x' }; store.save(B);
out.r2 = { obj: [B.plan.brief.assets.logo, B.plan.verification && B.plan.verification.at], disk: [store.load(id).plan.brief.assets.logo, store.load(id).plan.verification.at] };
A = store.load(id); B = store.load(id); A.title = 'E-title'; store.save(A); B.title = 'F-title'; out.r3 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).title, mine: B.title };
A = store.load(id); B = store.load(id); A.git.changes[0].pushed = true; store.save(A); B.git.changes.push({ no: 2 }); out.r4 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).git.changes };
A = store.load(id); B = store.load(id); A.tier = 'premium'; store.save(A); B.tier = 'premium'; out.r5 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).tier };
A = store.load(id); A.title = 'K-title'; store.save(A); const fresh = store.update(id, (f) => { f.git.changes.push({ no: 2, kind: 'later' }); f.status = 'idle'; }); out.r6 = { title: fresh.title, changes: fresh.git.changes.map((c) => c.no), disk: store.load(id).git.changes.map((c) => c.no) };
console.log(JSON.stringify(out));`;
const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_DIR: path.join(tmp, 'sites'), DATA_ROOT: tmp }, encoding: 'utf8' }); const out = JSON.parse(raw.trim().split('\n').pop());
assert.deepEqual(out.r1, { obj: ['A-title', 'standard'], disk: ['A-title', 'standard'] }, '다른 최상위 항목은 합쳐진다: ' + JSON.stringify(out.r1));
assert.deepEqual(out.r2, { obj: ['logo.png', 'x'], disk: ['logo.png', 'x'] }, '같은 객체 안의 다른 잎(plan.brief.assets.logo vs plan.verification)은 합쳐진다: ' + JSON.stringify(out.r2));
assert.equal(out.r3.thrown && out.r3.thrown.code, 'site_save_conflict', '같은 잎을 둘 다 다르게 바꾸면 거절: ' + JSON.stringify(out.r3)); assert.deepEqual(out.r3.thrown.fields, ['title']); assert.equal(out.r3.thrown.status, 409); assert.equal(out.r3.disk, 'E-title', '디스크의 먼저 쓴 값 보존'); assert.equal(out.r3.mine, 'F-title', '거절된 객체는 그대로');
assert.equal(out.r4.thrown && out.r4.thrown.code, 'site_save_conflict', '배열은 잎: git.changes 를 둘 다 바꾸면 거절: ' + JSON.stringify(out.r4)); assert.deepEqual(out.r4.thrown.fields, ['git.changes']); assert.deepEqual(out.r4.disk, [{ no: 1, pushed: true }], '디스크 보존');
assert.equal(out.r5.thrown, 'no-throw', '같은 값으로 바꾼 것은 충돌이 아니다: ' + JSON.stringify(out.r5)); assert.equal(out.r5.disk, 'premium');
assert.deepEqual(out.r6, { title: 'K-title', changes: [1, 2], disk: [1, 2] }, 'update() 는 최신 기록 위에 적용: ' + JSON.stringify(out.r6));
fs.rmSync(tmp, { recursive: true, force: true });
});
test('배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경(작업 중 "다시 푸시" 가 git.changes 의 반영 상태를 바꾸고, 작업 종료가 git.changes 에 새 버전을 더함): 작업의 늦은 저장은 디스크를 덮어쓰지 않고 충돌을 받아 최신 기록 위에 자기 결과만 다시 적용한다 — 다시 푸시의 verified 와 작업의 새 버전·commands·status 가 모두 남는다', async () => {
const fakeWrite = path.join(os.tmpdir(), `fake-claude-write-${process.pid}.sh`); fs.writeFileSync(fakeWrite, '#!/bin/sh\ndate +%s%N >> ai-change.html\nsleep 30\n', { mode: 0o755 }); // 사이트 폴더(cwd)에 파일을 바꿔 두고 멈춘다 → 중단되어도 작업 종료가 버전을 기록한다
S5 = await boot('remote-busy', { CLAUDE_BIN: fakeWrite, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` });
const { id } = await S5.mkSite(); const record = path.join(S5.tmp, 'sites', `${id}.json`);
const site = async () => (await S5.api(`/api/sites/${id}`, {}, 'a')).b; const waitIdle = async () => { for (let i = 0; i < 100; i++) { if (!(await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 끝나지 않음'); };
const waitRunning = async () => { for (let i = 0; i < 50; i++) { if ((await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 시작되지 않음'); };
try {
await waitRunning(); await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await waitIdle(); // 생성 작업(가짜 claude 가 붙잡음)은 끝낸다
const g = await S5.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const v = await S5.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">충돌 전 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v.status, 200, JSON.stringify(v.b));
let c = null; for (let i = 0; i < 60; i++) { c = ((await site()).git.changes || []).find((x) => x.commit === v.b.change.commit); if (c && c.remote === 'verified') break; await new Promise((r) => setTimeout(r, 100)); } assert.ok(c && c.remote === 'verified', JSON.stringify(c));
// 기록을 "아직 확인 안 됨" 으로 되돌리고 원격 변경 브랜치를 지운다 → 작업 중 "다시 푸시" 가 git.changes 를 실제로 바꾸게 된다
const rec0 = JSON.parse(fs.readFileSync(record, 'utf8')); const rc = rec0.git.changes.find((x) => x.commit === v.b.change.commit); rc.pushed = false; rc.remote = 'pending'; delete rc.verifiedAt; fs.writeFileSync(record, JSON.stringify(rec0, null, 2));
execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${v.b.change.branch}`]);
const n0 = ((await site()).commands || []).length;
const cmd = await S5.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '충돌 경쟁 작업' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b)); await waitRunning();
// 작업을 중단한다 → 작업 종료가 (중단 기록 저장 뒤) 버전 기록을 위해 커밋하는 동안(래퍼가 commit 을 2초 늦춤) "다시 푸시" 가 같은 git.changes 의 반영 상태를 바꾼다 → 작업의 그 다음 저장(새 버전 추가)은 같은 잎을 둘 다 바꾼 진짜 동시 변경
fs.writeFileSync(path.join(BARE, 'SLOW_COMMIT'), '');
await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await new Promise((r) => setTimeout(r, 700));
const retry = await S5.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b).slice(0, 300));
const rr = (retry.b.changes || []).find((x) => x.commit === v.b.change.commit); assert.ok(rr && rr.pushed === true && rr.remote === 'verified' && rr.verifiedAt, '커밋 중 다시 푸시가 기록을 바꿨다: ' + JSON.stringify(rr));
fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); await waitIdle(); await new Promise((r) => setTimeout(r, 800));
const after = await site(); const kept = (after.git.changes || []).find((x) => x.commit === v.b.change.commit);
assert.ok(kept && kept.pushed === true && kept.remote === 'verified', '작업의 늦은 저장이 다시 푸시의 결과(verified)를 덮어쓰지 않는다: ' + JSON.stringify(kept));
assert.equal(kept.verifiedAt, rr.verifiedAt, '다시 푸시가 쓴 확인 시각이 그대로다(덮어썼다가 배경 재확인으로 다시 verified 가 된 것이 아니다): ' + JSON.stringify({ kept: kept.verifiedAt, retry: rr.verifiedAt }));
const last = after.git.changes[after.git.changes.length - 1]; assert.ok(last && last.kind === 'ai-update' && last.no > kept.no, '작업의 새 버전도 기록된다: ' + JSON.stringify(after.git.changes.map((x) => [x.no, x.kind, x.remote])));
assert.equal((after.commands || []).length, n0 + 1, 'commands 도 남는다'); assert.equal(after.running, false); assert.ok(['idle', 'error'].includes(after.status), after.status);
assert.equal(fs.readFileSync(record, 'utf8').includes('"remote": "verified"') || fs.readFileSync(record, 'utf8').includes('"remote":"verified"'), true, '디스크 기록에도 verified');
} finally { try { fs.unlinkSync(fakeWrite); } catch (_) {} try { fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); } catch (_) {} pkillSleep(); }
});
test('gitstore.recordChangeStrict(직접 호출, 원격 없음): 첫 커밋 전 저장소에서 기록 저장이 실패하면 만들어진 커밋·브랜치를 지우고 add 로 올라간 인덱스를 비워 검증된 {ok:true} 를 돌려주고, onRollback 이 문제를 보고하면 ok:false 와 문제 목록을 돌려주며(거짓 성공 없음), 성공하면 pushed:false·원격 미설정 안내로 끝난다', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-gs-')); const site = path.join(tmp, 'www', 's1'); fs.mkdirSync(site, { recursive: true }); fs.writeFileSync(path.join(site, 'index.html'), '<p>a</p>');
const bare = path.join(tmp, 'origin.git'); execFileSync('git', ['init', '-q', '--bare', bare]);
fs.writeFileSync(path.join(bare, 'hooks', 'pre-receive'), `#!/bin/sh\necho push >> "${bare}/pushes.log"\nif [ -e "${bare}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
const script = `
const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const out = {};
const base = { siteId: 's1', siteDir: site, no: 1, kind: 'visual-edit', message: 'm', author: { name: 't', email: 't@x' } };
(async () => {
// 0) 준비 단계 실패(git init 불가 — 저장소 뿌리 읽기 전용): 페이지는 이미 바뀐 뒤이므로 onRollback 이 불리고, 만들다 만 git 폴더 없이 검증된 되돌리기
fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o555); fs.writeFileSync(site + '/index.html', '<p>b</p>');
try { await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => { fs.writeFileSync(site + '/index.html', '<p>a</p>'); return []; } }); out.r0 = 'no-throw'; } catch (e) { out.r0 = { stage: e.stage, rollback: e.rollback, message: e.message, gitDirExists: fs.existsSync(${JSON.stringify(path.join(tmp, 'git', 's1'))}), page: fs.readFileSync(site + '/index.html', 'utf8') }; }
fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o755);
fs.writeFileSync(site + '/index.html', '<p>b</p>');
try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw Object.assign(new Error('store down'), { code: 'EACCES' }); }, onRollback: () => { fs.writeFileSync(site + '/index.html', '<p>a</p>'); return []; } }); out.r1 = 'no-throw'; } catch (e) { out.r1 = { stage: e.stage, rollback: e.rollback, message: e.message }; }
out.r1state = { page: fs.readFileSync(site + '/index.html', 'utf8') };
fs.writeFileSync(site + '/index.html', '<p>c</p>');
try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw new Error('store down'); }, onRollback: () => ['페이지 되돌리기 생략'] }); out.r2 = 'no-throw'; } catch (e) { out.r2 = { rollback: e.rollback }; }
const rec = await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => [] }); out.r3 = { commit: !!(rec && rec.commit), sha: rec && rec.commit, no: rec && rec.no, pushed: rec && rec.pushed, error: rec && rec.error, branch: rec && rec.branch, remote: rec && rec.remote };
console.log(JSON.stringify(out));
})().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`;
const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: '', SOURCE_PROVIDER: 'none' }, encoding: 'utf8' });
const out = JSON.parse(raw.trim().split('\n').pop()); assert.ok(!out.fatal, raw);
assert.equal(out.r0.stage, 'prepare', '준비 단계 실패도 트랜잭션 안: ' + JSON.stringify(out.r0)); assert.equal(out.r0.rollback && out.r0.rollback.ok, true, JSON.stringify(out.r0)); assert.equal(out.r0.page, '<p>a</p>', 'onRollback 이 불려 페이지 복원'); assert.equal(out.r0.gitDirExists, false, '만들다 만 git 폴더 없음');
// 4) 원격 반영 조정(원격이 설정된 두 번째 프로세스): 원격이 없던 때의 기록(또는 기록 저장과 푸시 사이에 죽은 pending 기록)을 ls-remote 로 확인 → 없으면 변경 브랜치 + main 원자적 푸시 → 다시 ref 로 확인한 뒤에만 pushed:true
const script2 = `
const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const bare = ${JSON.stringify(bare)}; const out = {};
(async () => {
const r4 = { no: ${JSON.stringify(out.r3.no)}, kind: 'visual-edit', branch: ${JSON.stringify(out.r3.branch)}, commit: ${JSON.stringify(out.r3.sha)}, pushed: false, error: null, remote: 'pending' };
fs.writeFileSync(bare + '/REJECT', '');
await gs.reconcilePushes('s1', site, [r4]); out.r4reject = { pushed: r4.pushed, remote: r4.remote, error: r4.error };
fs.unlinkSync(bare + '/REJECT');
await gs.reconcilePushes('s1', site, [r4]); out.r4 = { pushed: r4.pushed, remote: r4.remote, error: r4.error, verifiedAt: !!r4.verifiedAt };
const r5 = { ...r4, pushed: false, remote: 'pending', verifiedAt: undefined }; // 이미 원격에 있는 기록: 확인만 하고 푸시하지 않는다
await gs.reconcilePushes('s1', site, [r5]); out.r5 = { pushed: r5.pushed, remote: r5.remote };
console.log(JSON.stringify(out));
})().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`;
const raw2 = execFileSync(process.execPath, ['-e', script2], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: bare, SOURCE_PROVIDER: 'gitea' }, encoding: 'utf8' });
const out2 = JSON.parse(raw2.trim().split('\n').pop()); assert.ok(!out2.fatal, raw2); Object.assign(out, out2);
const g = (...args) => execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(tmp, 'git', 's1'), GIT_WORK_TREE: site }, encoding: 'utf8' }).trim();
assert.equal(out.r1.stage, 'metadata', JSON.stringify(out.r1)); assert.equal(out.r1.rollback.ok, true, '첫 커밋 전 되감기 검증: ' + JSON.stringify(out.r1)); assert.match(out.r1.message, /store down/); assert.equal(out.r1state.page, '<p>a</p>');
assert.equal(out.r2.rollback.ok, false, '문제가 보고되면 ok 가 아니다: ' + JSON.stringify(out.r2)); assert.ok(out.r2.rollback.problems.some((p) => /페이지 되돌리기 생략/.test(p)), JSON.stringify(out.r2));
assert.equal(out.r3.commit, true, JSON.stringify(out.r3)); assert.equal(out.r3.pushed, false); assert.match(out.r3.error, /소스 보관 연동 미설정/); assert.equal(out.r3.branch, 'sites/s1/changes/0001-visual-edit'); assert.equal(out.r3.remote, undefined, '원격이 없으면 pending 이 아니다');
assert.equal(g('rev-list', '--count', 'HEAD'), '1', '실패한 두 번의 커밋은 남지 않고 성공한 커밋 하나'); assert.equal(g('for-each-ref', '--format=%(refname)', 'refs/heads/').split('\n').sort().join(' '), 'refs/heads/main refs/heads/sites/s1/changes/0001-visual-edit');
assert.equal(g('status', '--porcelain'), '');
assert.deepEqual([out.r4reject.pushed, out.r4reject.remote], [false, 'pending'], '거부된 조정은 pending 그대로: ' + JSON.stringify(out.r4reject)); assert.match(out.r4reject.error || '', /pre-receive hook declined|rejected by test hook|거부/, '거부 사유가 기록됨: ' + JSON.stringify(out.r4reject));
assert.deepEqual([out.r4.pushed, out.r4.remote, out.r4.error, out.r4.verifiedAt], [true, 'verified', null, true], '조정 푸시 뒤 ref 로 확인: ' + JSON.stringify(out.r4));
const refs = Object.fromEntries(execFileSync('git', ['--git-dir', bare, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim().split('\n').map((l) => l.split(' ')));
assert.equal(refs['refs/heads/sites/s1/changes/0001-visual-edit'], g('rev-parse', 'HEAD')); assert.equal(refs['refs/heads/sites/s1/main'], g('rev-parse', 'HEAD'));
assert.deepEqual([out.r5.pushed, out.r5.remote], [true, 'verified'], '이미 원격에 있으면 확인만: ' + JSON.stringify(out.r5));
assert.equal(fs.readFileSync(path.join(bare, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, 2, '푸시는 거부 1 + 성공 1 — 확인만 한 조정은 푸시하지 않는다');
fs.rmSync(tmp, { recursive: true, force: true });
});
test('원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html');
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8');
fs.chmodSync(dir, 0o555); // 폴더에 새 파일을 만들 수 없음 — 기존 파일을 직접 덮어쓰는 방식이면 여기서도 "성공" 하며 실패 때 페이지가 잘릴 수 있다
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>쓰기 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(dir, 0o755); }
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다/); assert.match(r.b.error, /그대로/);
assert.equal(fs.readFileSync(index, 'utf8'), before, '기존 페이지 보존'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 잔여 없음');
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 0, '실패한 저장은 버전을 만들지 않는다');
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 요청이 저장된다'); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장'));
});
test('AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)', async () => {
const fake = path.join(os.tmpdir(), `fake-claude-${process.pid}.sh`); fs.writeFileSync(fake, '#!/bin/sh\nsleep 30\n', { mode: 0o755 });
S2 = await boot('busy', { CLAUDE_BIN: fake });
const { id } = await S2.mkSite(); // quick 모드 생성은 바로 AI 작업을 시작한다 → 가짜 claude 가 30초 동안 멈춰 있어 작업이 실행 중
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); }
assert.equal(running, true, '작업 실행 중 상태: ' + S2.out().slice(-400));
const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200, '읽기는 가능');
const put = await S2.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(put.status, 409); assert.match(put.b.error, /AI 작업/);
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
global.__busy = { id, fake };
});
test('배경 AI 작업이 나중에 자기 사이트 객체를 저장해도, 작업 중에 허용되는 짧은 변경(기본 사항 PATCH·모델 등급 PUT·첨부 없는 기록 변경)은 조용히 사라지지 않는다 — 저장소가 읽었을 때 기준으로 바뀐 항목만 디스크의 최신 기록 위에 합친다(작업이 바꾼 commands·status 와 화면이 바꾼 title·tier 가 모두 남는다)', async () => {
const { id } = global.__busy;
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
const before = (await S2.api(`/api/sites/${id}`, {}, 'a')).b; const n0 = (before.commands || []).length;
const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '작업 중 변경 경쟁' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b));
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); } assert.equal(running, true, '가짜 claude(sleep 30)가 작업을 붙잡고 있다');
// 작업이 자기 객체를 들고 있는 동안 허용되는 짧은 변경 두 가지(실행 중 검사 없음)
const patch = await S2.api(`/api/sites/${id}`, { method: 'PATCH', body: JSON.stringify({ title: '작업 중 바꾼 제목' }) }, 'a'); assert.equal(patch.status, 200, JSON.stringify(patch.b)); assert.equal(patch.b.title, '작업 중 바꾼 제목');
const tier = (before.tier === 'basic') ? 'standard' : 'basic';
const model = await S2.api(`/api/sites/${id}/model`, { method: 'PUT', body: JSON.stringify({ tier }) }, 'a'); assert.equal(model.status, 200, JSON.stringify(model.b));
const fd = new FormData(); fd.append('files', new Blob(['hello attachment'], { type: 'text/plain' }), 'note-during-job.txt');
const up = await fetch(`${S2.B}/api/sites/${id}/uploads`, { method: 'POST', headers: { referer: `${S2.B}/`, cookie: S2.jar.a }, body: fd }); assert.equal(up.status, 201, '작업 중 첨부 업로드(같은 최상위 항목 attachments): ' + (await up.text()).slice(0, 200));
// 작업을 끝낸다 → 작업 코드가 자기 객체(제목·등급 변경 전)를 저장한다
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
await new Promise((r) => setTimeout(r, 300));
const after = (await S2.api(`/api/sites/${id}`, {}, 'a')).b;
assert.equal(after.running, false);
assert.equal(after.title, '작업 중 바꾼 제목', '작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: ' + JSON.stringify({ title: after.title, status: after.status }));
assert.equal(after.tier, tier, '모델 등급 변경도 남는다');
assert.equal((after.commands || []).length, n0 + 1, '작업 기록(commands)도 남는다'); assert.ok(['error', 'idle', 'cancelled'].includes(after.status) || true);
assert.ok((after.basicsChanged || []).includes('페이지 제목'), '기본 사항 변경 표식도 남는다: ' + JSON.stringify(after.basicsChanged));
assert.ok((after.attachments || []).some((a) => a.name === 'note-during-job.txt'), '작업 중 올린 첨부도 남는다(attachments 는 작업이 시작 때만 바꾸고 저장했으므로 화면의 추가가 산다): ' + JSON.stringify((after.attachments || []).map((a) => a.name)));
});
test('사이트 변경 잠금: 직접 편집 PUT 이 본문을 기다리는 사이에 AI 작업이 시작되면, 본문을 다 받은 뒤 잠금 안에서 다시 검사해 409 로 거절하고 파일을 바꾸지 않는다', async () => {
const { id, fake } = global.__busy; const index = path.join(S2.tmp, 'www', id, 'index.html');
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
assert.equal((await S2.api(`/api/sites/${id}`, {}, 'a')).b.running, false, '중단 뒤 쉬는 상태');
const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200); const before = fs.readFileSync(index, 'utf8');
const V = slowRequest(S2, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">작업 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev });
await new Promise((r) => setTimeout(r, 300)); // 처리기가 사전 검사를 지나 본문을 기다리는 상태
const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '제목을 바꿔 주세요' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b));
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); }
assert.equal(running, true, 'AI 작업 실행 중');
V.finish(); const rv = await V.done;
assert.equal(rv.status, 409, '본문을 받은 뒤 잠금 안에서 다시 검사: ' + JSON.stringify(rv.b)); assert.match(rv.b.error, /AI 작업/);
assert.equal(fs.readFileSync(index, 'utf8'), before, 'AI 작업 중 파일을 바꾸지 않는다');
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
try { fs.unlinkSync(fake); } catch (_) {}
});
test('읽기 요청은 부작용이 없다: x-ui-lang 헤더를 단 GET/HEAD/OPTIONS 가 여러 번·동시에 와도 사이트 기록 파일(JSON·saveId·Suite rev)이 바뀌지 않고, 본문을 기다리는 직접 편집 저장과 충돌하지 않는다 — 언어는 변경 요청(잠금 안)으로만 저장된다', async () => {
const { id } = await S.mkSite(); await idle(S, id); const record = path.join(S.tmp, 'sites', `${id}.json`);
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">읽기 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }); await new Promise((r) => setTimeout(r, 200));
const before = fs.readFileSync(record, 'utf8');
const reads = []; for (let i = 0; i < 12; i++) reads.push(S.api(`/api/sites/${id}${i % 3 === 1 ? '/files/index.html' : i % 3 === 2 ? '/visual/index.html' : ''}`, { headers: { 'x-ui-lang': i % 2 ? 'en' : 'ja' } }, 'a'));
reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'HEAD', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status })));
reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'OPTIONS', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status })));
const rs = await Promise.all(reads); for (const r of rs.slice(0, 12)) assert.equal(r.status, 200, JSON.stringify(r.b).slice(0, 120));
assert.equal(fs.readFileSync(record, 'utf8'), before, '읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)');
V.finish(); const rv = await V.done; assert.equal(rv.status, 200, '읽기들과 겹친 저장은 그대로 성공: ' + JSON.stringify(rv.b).slice(0, 200));
const after = JSON.parse(fs.readFileSync(record, 'utf8')); assert.notEqual(after.uiLang, 'ja', '읽기 헤더가 언어를 저장하지 않았다'); assert.ok((after.manualEdits || []).some((e) => e.kind === 'visual'));
// 변경 요청(잠금 안)의 헤더는 언어를 저장한다(제품 동작 유지)
const patch = await S.api(`/api/sites/${id}`, { method: 'PATCH', headers: { 'x-ui-lang': 'en' }, body: JSON.stringify({ purpose: '언어 저장 확인' }) }, 'a'); assert.equal(patch.status, 200);
assert.equal(JSON.parse(fs.readFileSync(record, 'utf8')).uiLang, 'en', '변경 요청으로만 언어가 저장된다');
});
test('사이트 폐기는 잠금 안에서 다시 읽고 폐기 표식을 원자적으로 잡은 뒤 배포 서버 정리(바깥 await)를 하고, 다시 잠금을 잡아 같은 표식을 확인한 뒤에만 파일·기록을 지운다: 폐기 중(가짜 ssh 2초)에 온 직접 편집 저장·소스 저장·AI 작업 시작·기본 사항 변경·두 번째 폐기는 모두 409; 배포 서버 정리가 실패하면 표식을 풀고 기록(deleteFailed)만 남겨 사이트는 그대로 쓸 수 있다; 성공하면 잡은 사이트만 지워지고 다른 사이트는 그대로다', async () => {
const SSHWRAP = path.join(os.tmpdir(), `wm-ve-sshwrap-${process.pid}`); const FLAGS = path.join(os.tmpdir(), `wm-ve-sshflags-${process.pid}`); fs.mkdirSync(SSHWRAP, { recursive: true }); fs.mkdirSync(FLAGS, { recursive: true });
fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\nsleep 2\nif [ -e "${FLAGS}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 });
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-destroy-'));
fs.writeFileSync(path.join(tmp, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '가짜 배포 서버', type: 'ssh', baseDir: '/srv/sites', publicBaseUrl: '', note: '', manageVhost: false, host: 'fake.test', port: 22, user: 'root', createdAt: new Date().toISOString() }] }));
const S6 = await boot('destroy', { CLAUDE_BIN: FAKE_FAIL, PATH: `${SSHWRAP}:${process.env.PATH}` }, null, tmp);
const markDeployed = (id) => { const f = path.join(S6.tmp, 'sites', `${id}.json`); const s = JSON.parse(fs.readFileSync(f, 'utf8')); s.deployments = [{ at: new Date().toISOString(), targetId: 't1', targetName: '가짜 배포 서버', ok: true, ready: true, phase: 'ready', url: 'http://fake.test/x/', domains: [], domainUrls: [] }]; fs.writeFileSync(f, JSON.stringify(s, null, 2)); };
const siteOf = async (id) => S6.api(`/api/sites/${id}`, {}, 'a');
try {
// 1) 폐기 중의 변경 요청은 모두 409, 폐기는 성공, 다른 사이트는 그대로
const A = await S6.mkSite(); await idle(S6, A.id); markDeployed(A.id); const B = await S6.mkSite(); await idle(S6, B.id);
const titleA = (await siteOf(A.id)).b.title; const g = await S6.api(`/api/sites/${A.id}/visual/index.html`, {}, 'a');
const D = S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'); await new Promise((r) => setTimeout(r, 400)); // 가짜 ssh 가 2초 멈춰 있는 동안
const during = await Promise.all([
S6.api(`/api/sites/${A.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">폐기 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'),
S6.api(`/api/sites/${A.id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '폐기 중 소스') }) }, 'a'),
S6.api(`/api/sites/${A.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '폐기 중 작업' }) }, 'a'),
S6.api(`/api/sites/${A.id}`, { method: 'PATCH', body: JSON.stringify({ title: '폐기 중 제목' }) }, 'a'),
S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'),
]);
for (const [i, r] of during.entries()) { assert.equal(r.status, 409, `폐기 중 변경 요청 #${i} 는 409: ` + JSON.stringify(r.b)); assert.match(r.b.error, /폐기/, `#${i} 폐기 안내`); }
assert.equal((await siteOf(A.id)).status, 200, '폐기 중 읽기는 된다'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', A.id, 'index.html')), '폐기 확정 전에는 파일이 있다');
const rd = await D; assert.equal(rd.status, 200, '폐기 성공: ' + JSON.stringify(rd.b)); assert.equal(rd.b.deleted, A.id);
assert.equal((await siteOf(A.id)).status, 404); assert.ok(!fs.existsSync(path.join(S6.tmp, 'www', A.id)), '사이트 폴더 삭제'); assert.ok(!fs.existsSync(path.join(S6.tmp, 'sites', `${A.id}.json`)), '기록 삭제');
assert.equal((await siteOf(B.id)).status, 200, '다른 사이트는 그대로'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', B.id, 'index.html')));
assert.ok(!fs.readFileSync(path.join(S6.tmp, 'www', B.id, 'index.html'), 'utf8').includes('폐기 중'), '409 된 저장은 어디에도 쓰이지 않았다');
// 2) 배포 서버 정리 실패: 표식을 풀고 기록만 남기며 사이트는 그대로 쓸 수 있다(저장·작업 시작 가능)
const C = await S6.mkSite(); await idle(S6, C.id); markDeployed(C.id); const titleC = (await siteOf(C.id)).b.title; fs.writeFileSync(path.join(FLAGS, 'SSH_FAIL'), '');
const rf = await S6.api(`/api/sites/${C.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleC }) }, 'a'); assert.ok(rf.status >= 400, '정리 실패는 오류: ' + JSON.stringify(rf.b)); assert.match(rf.b.error, /배포 서버/);
fs.unlinkSync(path.join(FLAGS, 'SSH_FAIL'));
const sc = await siteOf(C.id); assert.equal(sc.status, 200, '사이트 유지'); assert.ok(sc.b.deleteFailed && sc.b.deleteFailed.detail, '실패 기록'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', C.id, 'index.html')), '파일 유지');
const g2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, {}, 'a'); const ok2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">실패 뒤 저장</h1>', baseHash: g2.b.hash, rev: g2.b.rev }) }, 'a'); assert.equal(ok2.status, 200, '표식이 풀려 저장된다: ' + JSON.stringify(ok2.b));
const cmd = await S6.api(`/api/sites/${C.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '실패 뒤 작업' }) }, 'a'); assert.equal(cmd.status, 202, '표식이 풀려 작업도 시작된다: ' + JSON.stringify(cmd.b)); await idle(S6, C.id);
} finally { try { S6.child.kill('SIGTERM'); } catch (_) {} fs.rmSync(SSHWRAP, { recursive: true, force: true }); fs.rmSync(FLAGS, { recursive: true, force: true }); }
});
test('사이트 변경 잠금(전체 감사): 본문을 기다리는 사이에 직접 편집 저장이 끝난 다국어 설정 저장·버전 복원·AI 작업 시작은 잠금 안에서 사이트 기록을 다시 읽으므로 그 저장의 버전·manualEdits 를 덮어쓰지 않는다', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const PAD = 'x'.repeat(64);
const siteOf = async () => (await S.api(`/api/sites/${id}`, {}, 'a')).b; const hasCommit = (s, c) => (s.git.changes || []).some((x) => x.commit === c);
// (a) 다국어·현지 설정 저장(PUT /langs/settings) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 설정 저장이 그 버전·manualEdits 를 지우면 안 된다
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const L = slowRequest(S, 'PUT', `/api/sites/${id}/langs/settings`, { timeZone: 'Asia/Seoul', pad: PAD }); await new Promise((r) => setTimeout(r, 300));
const v1 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">경쟁-설정</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v1.status, 200, JSON.stringify(v1.b));
L.finish(); const rl = await L.done; assert.equal(rl.status, 200, JSON.stringify(rl.b));
let s = await siteOf(); assert.ok(hasCommit(s, v1.b.change.commit), '설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.equal((s.locale || {}).timeZone, 'Asia/Seoul', '설정도 저장됨'); assert.ok((s.manualEdits || []).some((e) => e.kind === 'visual'), 'manualEdits 유지');
// (b) 버전 복원(POST /versions/<no>/restore) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 복원은 잠금 안에서 다시 읽은 기록 위에 새 복원 버전을 더한다(먼저 끝난 버전이 목록에서 사라지지 않는다)
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const R = slowRequest(S, 'POST', `/api/sites/${id}/versions/${v1.b.change.no}/restore`, { pad: PAD }); await new Promise((r) => setTimeout(r, 300));
const v2 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">경쟁-복원</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v2.status, 200, JSON.stringify(v2.b));
R.finish(); const rrs = await R.done; assert.equal(rrs.status, 200, JSON.stringify(rrs.b).slice(0, 300));
s = await siteOf(); assert.ok(hasCommit(s, v2.b.change.commit), '복원이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind)));
const last = s.git.changes[s.git.changes.length - 1]; assert.equal(last.kind, 'restore'); assert.ok(last.no > v2.b.change.no, '복원 버전은 먼저 끝난 저장 뒤 번호'); assert.ok(fs.readFileSync(index, 'utf8').includes('경쟁-설정'), '복원 내용은 #' + v1.b.change.no + ' 의 페이지');
// (c) AI 작업 시작(POST /commands) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 작업은 잠금 안에서 다시 읽은 기록으로 시작·종료 저장을 하므로 그 버전이 남는다
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const C = slowRequest(S, 'POST', `/api/sites/${id}/commands`, { instruction: '제목을 바꿔 주세요', pad: PAD }); await new Promise((r) => setTimeout(r, 300));
const v3 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">경쟁-작업</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v3.status, 200, JSON.stringify(v3.b));
C.finish(); const rc = await C.done; assert.equal(rc.status, 202, JSON.stringify(rc.b)); await idle(S, id);
s = await siteOf(); assert.ok(hasCommit(s, v3.b.change.commit), '작업의 종료 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.ok((s.commands || []).length >= 1, '작업 기록도 남음');
});
test('화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다', async () => {
const page = await fetch(`${S.B}/`, { headers: { referer: `${S.B}/`, cookie: S.jar.a }, redirect: 'manual' }); assert.equal(page.status, 200); const html = await page.text();
assert.match(html, /<button id="btnVisual"[^>]*aria-pressed="false"/);
for (const id of ['veBar', 'veState', 'veSave', 'veUndo', 'veRedo', 'veDiscard', 'veClose', 'vePalette', 'veInspector', 'vePaletteToggle', 'veInspectorToggle']) assert.ok(html.includes(`id="${id}"`), id);
assert.match(html, /id="veState"[^>]*aria-live="polite"/); assert.match(html, /id="vePalette"[^>]*role="region"[^>]*aria-label=/); assert.match(html, /id="veInspector"[^>]*role="region"[^>]*aria-label=/);
assert.ok(html.includes('id="btnToggleSource"'), '소스 편집기 유지'); assert.ok(html.includes('id="btnMobile"'), '모바일 미리보기 유지');
for (const f of ['/visual-editor-core.js', '/visual-editor-ui.js']) { assert.ok(html.includes(`src="${f}"`), f); const r = await fetch(S.B + f); assert.equal(r.status, 200, f); const t = await r.text(); assert.ok(t.length > 1000); }
const app = await (await fetch(S.B + '/app.js')).text(); assert.ok(app.includes("'visual-edit'"), '버전 목록에 직접 편집 종류'); assert.ok(app.includes('VisualEditor'), 'app.js 가 편집기와 연결');
const css = await (await fetch(S.B + '/style.css')).text(); assert.ok(css.includes('.ve-bar') && css.includes('.ve-side') && /@media \(max-width: 900px\)[^@]*\.ve-side/.test(css.replace(/\n/g, ' ')), '반응형 규칙');
});
+817
View File
@@ -0,0 +1,817 @@
'use strict';
// v1.8.0 직접 편집(시각 편집기) 단위 검증 — 서버 순수 모듈 lib/visual-editor.js 와 브라우저·Node 공용 핵심 public/visual-editor-core.js.
// 실행: npm test (node --test). 브라우저 없이 문서 변환·저장 검증·실행 취소/다시 실행·메시지 규약·구성 요소 목록을 검사한다.
const test = require('node:test'); const assert = require('node:assert/strict');
const path = require('path'); const vm = require('vm'); const fs = require('fs');
const ROOT = path.join(__dirname, '..');
const ve = require(ROOT + '/lib/visual-editor');
const core = require(ROOT + '/public/visual-editor-core');
/** 시험용 페이지: 문서형·머리의 스타일/스크립트·본문의 인라인 핸들러·javascript: 링크·iframe·본문 끝 스크립트를 모두 가진다 */
const PAGE = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>T</title>
<link rel="stylesheet" href="style.css">
<style>h1{color:red}</style>
<script src="/wm-stats.js"></script>
<script>window.boot = 1 // <b>not html</b></script>
</head>
<body class="home">
<header><h1 id="t">안녕</h1><a href="javascript:alert(1)" onclick="go()">링크</a></header>
<main><p>본문</p><img src="images/a.png" alt="a"><iframe src="https://example.com/x"></iframe></main>
<script src="/wm-modules.js"></script>
</body>
</html>
`;
const OPTS = { baseHref: 'http://h/abc/', session: 'sess-1234', nonce: 'nonce-5678', parentOrigin: 'http://h', runtime: 'console.log("rt")' };
test('splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다', () => {
const s = ve.splitDocument(PAGE);
assert.equal(s.hasBody, true);
assert.ok(s.prefix.endsWith('<body class="home">'), s.prefix.slice(-40));
assert.ok(s.prefix.includes('<style>h1{color:red}</style>'));
assert.ok(s.body.startsWith('\n<header>') && s.body.includes('<script src="/wm-modules.js"></script>'));
assert.ok(s.suffix.startsWith('</body>') && s.suffix.trimEnd().endsWith('</html>'));
assert.equal(s.prefix + s.body + s.suffix, PAGE, '세 조각을 이으면 원문');
const frag = ve.splitDocument('<p>조각</p>'); assert.equal(frag.hasBody, false); assert.equal(frag.body, '<p>조각</p>');
});
test('prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다', () => {
const html = ve.prepareCanvas(PAGE, OPTS);
assert.match(html, /<meta http-equiv="Content-Security-Policy" content="[^"]*script-src 'nonce-nonce-5678'/);
assert.ok(html.includes('<base href="http://h/abc/"'), 'base 주입');
// 원래 스크립트는 모두 실행되지 않는 종류로 바뀌고(외부 src 포함), 내용은 그대로 남는다
const scripts = [...html.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]);
const original = scripts.filter((s) => !/nonce="nonce-5678"/.test(s));
assert.equal(original.length, 3, JSON.stringify(scripts));
for (const s of original) assert.match(s, /type="text\/x-wm-ve-disabled"/);
assert.ok(html.includes('window.boot = 1 // <b>not html</b>'), '스크립트 내용 보존');
assert.ok(!/\sonclick=/.test(html) && html.includes('data-wm-ve-on-click="go()"'), '인라인 핸들러는 보존 속성으로');
assert.ok(!/\shref="javascript:/.test(html) && html.includes('data-wm-ve-href="javascript:alert(1)"'), 'javascript: 링크 비활성화');
assert.ok(/<iframe data-wm-ve-src="https:\/\/example\.com\/x">/.test(html), 'iframe 은 불러오지 않는다');
assert.ok(html.includes('<title>T</title>') && html.includes('<style>h1{color:red}</style>') && html.includes('<body class="home">'), '머리·스타일·본문 태그 보존');
// 런타임은 nonce 를 가진 편집기 전용 스크립트로, 설정(세션·부모 출처)을 함께 넣는다
assert.ok(html.includes('"session":"sess-1234"') && html.includes('"parentOrigin":"http://h"'));
assert.ok(/<script nonce="nonce-5678" data-wm-ve-ui>console\.log\("rt"\)<\/script>/.test(html));
// 런타임 안의 </script> 는 문서를 끊지 못한다
const h2 = ve.prepareCanvas(PAGE, { ...OPTS, runtime: 'var s = "</script><img src=x onerror=alert(1)>";' });
assert.ok(!h2.includes('</script><img src=x onerror'), '런타임 문자열의 </script> 는 이스케이프');
});
test('restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다', () => {
const body = '<header data-wm-ve-id="3"><h1 id="t" contenteditable="true" data-wm-ve-editable="" class="wm-ve-selected big" spellcheck="false" data-wm-ve-spellcheck="">안녕하세요</h1>'
+ '<div data-wm-ve-ui="" class="wm-ve-toolbar"><button onclick="x()">삭제</button><script>evil()</script></div>'
+ '<a data-wm-ve-href="javascript:alert(1)" data-wm-ve-on-click="go()">링크</a></header>'
+ '<main><iframe data-wm-ve-src="https://example.com/x"></iframe><script type="text/x-wm-ve-disabled">a()</script><script type="text/x-wm-ve-disabled" data-wm-ve-orig-type="module">b()</script></main>';
const out = ve.restoreFromCanvas(body);
assert.equal(out, '<header><h1 id="t" class="big">안녕하세요</h1><a href="javascript:alert(1)" onclick="go()">링크</a></header>'
+ '<main><iframe src="https://example.com/x"></iframe><script>a()</script><script type="module">b()</script></main>');
});
test('validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다', () => {
const canvasBody = ve.splitDocument(ve.prepareCanvas(PAGE, OPTS)).body.replace('안녕', '안녕하세요');
const r = ve.validateVisualSave({ original: PAGE, body: canvasBody, maxBytes: 1024 * 1024 });
const s0 = ve.splitDocument(PAGE); const s1 = ve.splitDocument(r.html);
assert.equal(s1.prefix, s0.prefix, '머리 보존'); assert.equal(s1.suffix, s0.suffix, '꼬리 보존');
assert.ok(s1.body.includes('<h1 id="t">안녕하세요</h1>'));
assert.ok(s1.body.includes('<a href="javascript:alert(1)" onclick="go()">링크</a>'), '원래 있던 핸들러·링크는 보존');
assert.ok(s1.body.includes('<script src="/wm-modules.js"></script>'), '본문 끝 스크립트 보존');
assert.ok(!/data-wm-ve|x-wm-ve-disabled|contenteditable/.test(r.html), '편집기 표식 없음');
assert.equal(typeof r.hash, 'string');
});
test('validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다', () => {
const base = ve.splitDocument(PAGE).body;
const reject = (body, re, status = 400, opts = {}) => { let err = null; try { ve.validateVisualSave({ original: PAGE, body, maxBytes: 1024 * 1024, ...opts }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + re); assert.equal(err.status, status, err.message); assert.match(err.message, re); };
reject(base + '<script>alert(1)</script>', /스크립트/);
reject(base.replace('<script src="/wm-modules.js">', '<script src="/evil.js">'), /스크립트/);
reject(base.replace('<p>본문</p>', '<p onmouseover="steal()">본문</p>'), /이벤트 속성/);
reject(base.replace('<p>본문</p>', '<p>본문 <a href="javascript:void(0)">x</a></p>'), /허용되지 않는 (링크|주소)/);
reject(base.replace('<p>본문</p>', '<a href="data:text/html,<b>x</b>">x</a>'), /허용되지 않는 (링크|주소)/);
reject(base.replace('<p>본문</p>', '<p>본문</p><iframe src="https://evil.example/"></iframe>'), /iframe/);
reject(base.replace('<p>본문</p>', '<object data="x.swf"></object>'), /object/);
reject(base.replace('<p>본문</p>', '<p style="width:expression(alert(1))">본문</p>'), /스타일/);
reject(base.replace('<p>본문</p>', '<body><p>본문</p></body>'), /html·head·body/);
reject(base, /너무 큽니다/, 413, { maxBytes: 10 });
reject(42, /문자열/);
reject(base + '<script type="text/x-wm-ve-disabled" data-wm-ve-ui>x</script><script>fresh()</script>', /스크립트/);
});
test('validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절', () => {
const base = ve.splitDocument(PAGE).body;
const r = ve.validateVisualSave({ original: PAGE, body: base.replace('<script src="/wm-modules.js"></script>', '').replace('<iframe src="https://example.com/x"></iframe>', ''), maxBytes: 1e6 });
assert.ok(!r.html.includes('/wm-modules.js') && !r.html.includes('<iframe'));
assert.throws(() => ve.validateVisualSave({ original: PAGE, body: base + '<script src="/wm-modules.js"></script>', maxBytes: 1e6 }), /스크립트/);
});
test('validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다', () => {
assert.throws(() => ve.validateVisualSave({ original: '<html><head><title>x</title></head></html>', body: '<p>a</p>', maxBytes: 1e6 }), /body/);
const r = ve.validateVisualSave({ original: '<p>조각</p>', body: '<p>조각 수정</p>', maxBytes: 1e6 }); assert.equal(r.html, '<p>조각 수정</p>');
});
test('isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티(&colon; &Tab; &NewLine; &sol;)·숫자 엔티티(10/16진, 대소문자, 세미콜론 없음)·제어 문자·대소문자 섞임으로 감춘 javascript:/vbscript:/data:text 주소를 모든 주소 속성에서 거절하고, 모르는 엔티티로 흐린 스킴도 거절한다', () => {
const bads = ['jav&#x61;script&colon;alert(1)', 'javascript&colon;alert(1)', 'JaVa&Tab;ScRiPt:alert(1)', '&NewLine;javascript:alert(1)', 'java&NewLine;script&colon;alert(1)', '&#x6a;avascript:alert(1)', '&#106;avascript:alert(1)', '&#X6A;&#x61;vascript&colon;alert(1)', 'j&#97va&#115cript&#58alert(1)',
'java\u0000script:alert(1)', ' javascript:alert(1)', 'javascript\t:alert(1)', 'java​script:alert(1)', 'java&bogus;script:alert(1)', 'vbscript&colon;x', 'data&colon;text/html,<b>x</b>', 'data:text&sol;html,x', 'DATA:TEXT/HTML;base64,PGI+', '&#x6A;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;:1', '&#0000106;avascript:1', 'javascript&#x3a;alert(1)'];
for (const b of bads) assert.equal(ve.isDangerousUrl(b), true, '거절: ' + JSON.stringify(b));
const goods = ['https://a/b?x=1&amp;y=2', 'about.html?x&colon;y', 'mailto:a@b.c', 'tel:+82-10', '#top', 'data:image/png;base64,AAAA', '/path/with&amp;x', 'images/a.png', '//cdn.example/x.js', 'https://e/?q=javascript:x'];
for (const g of goods) assert.equal(ve.isDangerousUrl(g), false, '허용: ' + JSON.stringify(g));
// 서버 변환: 감춘 주소도 캔버스에서 보존 속성으로 바뀐다
const canvas = ve.prepareCanvas('<body><a href="javascript&colon;alert(1)">x</a><img src="&#x6a;avascript:alert(1)"></body>', OPTS);
assert.ok(!/\shref="javascript&colon;/.test(canvas) && canvas.includes('data-wm-ve-href="javascript&colon;alert(1)"'), canvas); assert.ok(canvas.includes('data-wm-ve-src="&#x6a;avascript:alert(1)"'));
// 저장 검증: 어떤 주소 속성이든 거절
const base = ve.splitDocument(PAGE).body;
for (const [tag, attr] of [['a', 'href'], ['img', 'src'], ['form', 'action'], ['button', 'formaction'], ['video', 'poster'], ['use', 'xlink:href'], ['a', 'ping'], ['img', 'srcset'], ['blockquote', 'cite'], ['img', 'longdesc'], ['body', 'background']]) {
for (const b of ['javascript&colon;alert(1)', '&#x6a;avascript:alert(1)', 'JaVa&Tab;ScRiPt:alert(1)']) {
const body = base.replace('<p>본문</p>', `<${tag} ${attr}="${b}">x</${tag}>`);
let err = null; try { ve.validateVisualSave({ original: PAGE, body, maxBytes: 1e6 }); } catch (e) { err = e; }
assert.ok(err && err.status === 400, `${tag}[${attr}]=${b} 거절`); assert.match(err.message, tag === 'body' ? /html·head·body|허용되지 않는/ : /허용되지 않는/);
}
}
});
test('prepareCanvas: 주석·원문(script/style/title) 안의 <head> 흉내·<header> 는 무시하고 진짜 <head> 뒤에만 CSP·런타임을 한 번 넣으며, <head> 가 없으면 진짜 <head> 를 만든다', () => {
const page = '<!doctype html><!--<head>--><html lang="ko"><script>var s = "<head>"</script><style>/*<head>*/</style><title><head></title><head><meta charset="utf-8"></head><body><header><h1>x</h1><script>evil()</script></header></body></html>';
const out = ve.prepareCanvas(page, OPTS);
assert.equal((out.match(/Content-Security-Policy/g) || []).length, 1, 'CSP 한 번'); assert.equal((out.match(/console\.log\("rt"\)/g) || []).length, 1, '런타임 한 번');
assert.ok(out.includes('<!--<head>-->'), '주석은 그대로'); assert.ok(out.includes('var s = "<head>"'), '스크립트 원문 그대로'); assert.ok(out.includes('<title><head></title>'), 'title 원문 그대로');
assert.ok(out.includes('<head><meta http-equiv="Content-Security-Policy"'), '진짜 <head> 바로 뒤에'); assert.ok(out.indexOf('Content-Security-Policy') > out.indexOf('</title>'), 'title 뒤의 진짜 head');
assert.ok(out.includes('<script type="text/x-wm-ve-disabled">evil()</script>'), '원래 스크립트는 비활성화된 채로');
// <head> 가 없고 <header> 만 있는 문서: <html> 바로 뒤에 진짜 <head> 를 만들고 <header> 는 건드리지 않는다
const out2 = ve.prepareCanvas('<html><body><header><h1>x</h1></header></body></html>', OPTS);
assert.ok(out2.startsWith('<html><head><meta http-equiv="Content-Security-Policy"'), out2.slice(0, 80)); assert.ok(out2.includes('</head><body><header><h1>x</h1></header>'), '<header> 안에 넣지 않는다'); assert.equal((out2.match(/Content-Security-Policy/g) || []).length, 1);
// <html> 도 없고 <body> 만 있으면 <body> 앞에 머리를 만든다 · 아무 구조도 없는 조각은 문서로 감싼다 · 주석 속 <head> 만 있는 조각도 진짜 머리를 얻는다
const out3 = ve.prepareCanvas('<!doctype html><body class="x"><p>a</p></body>', OPTS); assert.ok(out3.startsWith('<!doctype html><head><meta http-equiv="Content-Security-Policy"') && out3.includes('</head><body class="x"><p>a</p></body>'), out3.slice(0, 120));
const out4 = ve.prepareCanvas('<!--<head>--><p>frag</p>', OPTS); assert.ok(out4.startsWith('<!doctype html><html><head><meta http-equiv="Content-Security-Policy"') && out4.includes('<body><!--<head>--><p>frag</p></body>'), out4.slice(0, 160));
const out5 = ve.prepareCanvas('<html><!-- <head> --><body><p>a</p></body></html>', OPTS); assert.ok(out5.startsWith('<html><head><meta http-equiv="Content-Security-Policy"') && out5.includes('</head><!-- <head> --><body>'), out5.slice(0, 160));
});
test('validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 스크립트·핸들러·위험 주소·iframe/object 를 본문으로 복사해 오면 거절한다(완성 문서 전체를 원본 전체와 비교) — 본문 끝으로 옮겨진 꼬리 스크립트는 허용', () => {
const orig = '<!doctype html><html><head><script src="/head.js"></script><link rel="x" href="javascript:h()"><object data="x.swf"></object><iframe src="https://e/"></iframe></head><body onload="init()"><p>본문</p></body></html>';
const reject = (body, re) => { let err = null; try { ve.validateVisualSave({ original: orig, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body); assert.equal(err.status, 400); assert.match(err.message, re); };
reject('<p>본문</p><script src="/head.js"></script>', /스크립트/);
reject('<p onload="init()">본문</p>', /이벤트 속성/);
reject('<p>본문</p><a href="javascript:h()">x</a>', /허용되지 않는/);
reject('<p>본문</p><iframe src="https://e/"></iframe>', /iframe/);
reject('<p>본문</p><object data="x.swf"></object>', /object/);
const ok = ve.validateVisualSave({ original: orig, body: '<p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(ok.html.includes('<body onload="init()"><p>본문 수정</p></body>'));
// 브라우저가 </body> 뒤의 스크립트를 본문 끝으로 옮겨 돌려준 경우: 중복 없이 한 번만 남고 허용
const orig2 = '<html><head></head><body><p>a</p></body><script src="/tail.js"></script></html>';
const r2 = ve.validateVisualSave({ original: orig2, body: '<p>a 수정</p><script src="/tail.js"></script>', maxBytes: 1e6 });
assert.equal((r2.html.match(/tail\.js/g) || []).length, 1, r2.html); assert.ok(r2.html.includes('<p>a 수정</p>'));
assert.throws(() => ve.validateVisualSave({ original: orig2, body: '<p>a</p><script src="/tail.js"></script><script src="/tail.js"></script>', maxBytes: 1e6 }), /스크립트/, '두 번 복사는 거절');
});
test('validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(animate·set·animateMotion·animateTransform·animateColor·discard·mpath — 대소문자·접두어 포함)는 정적 주소 검사를 지나 href 를 javascript: 로 바꿀 수 있으므로 새로 넣거나 바꾸면 거절하고, 원래 있던 것은 그 수까지만(삭제 허용), 캔버스에서는 실행되지 않는 이름으로 바꿨다가 복원 때 되돌린다', () => {
const base = ve.splitDocument(PAGE).body;
const reject = (body, re, original = PAGE) => { let err = null; try { ve.validateVisualSave({ original, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body.slice(0, 120)); assert.equal(err.status, 400, err.message); assert.match(err.message, re); };
// 1) 저장형 XSS 우회: 정적 href 는 "#x" 라 주소 검사를 지나지만 animate/set 이 뒤에 href 를 javascript: 로 바꾼다(Chromium 에서 실행됨)
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text></a><animate attributeName="href" values="javascript:alert(1)" begin="0s" xlink:href="#a"/></svg>'), /animate/);
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text><set attributeName="xlink:href" to="javascript:alert(1)"/></a></svg>'), /set/);
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text><SET attributeName="href" to="data:text/html,<b>x</b>"></SET></a></svg>'), /set/);
// 2) 값에 위험 주소가 없어도 SMIL 요소 자체를 새로 넣을 수 없다(fail-closed) — 대소문자·이름 공간 접두어 형태 모두
for (const tag of ['animate', 'set', 'animateMotion', 'animateTransform', 'animateColor', 'discard', 'mpath', 'ANIMATE', 'AnimateMotion', 'svg:animate', 'SVG:Set', 'svg:mpath']) {
reject(base.replace('<p>본문</p>', `<svg><rect width="1" height="1"/><${tag} attributeName="x" to="1" dur="1s"/></svg>`), new RegExp(tag.replace(/^[^:]+:/, '').toLowerCase(), 'i'));
reject(base.replace('<p>본문</p>', `<svg><rect width="1" height="1"/><${tag} attributeName="x" to="1" dur="1s"></${tag}></svg>`), new RegExp(tag.replace(/^[^:]+:/, '').toLowerCase(), 'i'));
}
// 3) 원래 있던 SMIL: 그대로 두거나 지우는 것은 허용, 속성을 바꾸거나 하나 더 넣으면 거절(기존 목록 비교 규칙)
const orig = '<!doctype html><html><head><title>s</title></head><body><svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문</p></body></html>';
const same = ve.validateVisualSave({ original: orig, body: '<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(same.html.includes('<animate attributeName="r" to="5" dur="1s"/>') && same.html.includes('본문 수정'));
const gone = ve.validateVisualSave({ original: orig, body: '<svg><circle r="1"></circle></svg><p>본문</p>', maxBytes: 1e6 }); assert.ok(!/<animate/.test(gone.html), '삭제 허용');
reject('<svg><circle r="1"><animate attributeName="href" to="javascript:alert(1)" dur="1s"/></circle></svg><p>본문</p>', /animate/, orig);
reject('<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문</p>', /animate/, orig);
reject('<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle><a href="#q"><set attributeName="href" to="javascript:x()"/></a></svg><p>본문</p>', /set/, orig);
// 4) 캔버스: SMIL 요소는 실행되지 않는 이름(wm-ve-x-…)으로 바뀌고(여는·닫는 태그 모두), 복원 때 원래 이름으로 돌아와 그대로 저장된다
const page2 = '<html><head></head><body><svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/><animateMotion dur="1s"><mpath xlink:href="#p"/></animateMotion><set attributeName="x" to="1"></set></circle></svg><p>본문</p></body></html>';
const canvas = ve.prepareCanvas(page2, OPTS);
assert.ok(!/<\/?(animate|animateMotion|mpath|set)[\s>\/]/i.test(canvas), 'SMIL 요소 이름이 캔버스에 남지 않는다: ' + canvas.slice(canvas.indexOf('<svg'), canvas.indexOf('</svg>')));
assert.ok(canvas.includes('<wm-ve-x-animate attributeName="r" to="5" dur="1s" />') && canvas.includes('<wm-ve-x-animateMotion dur="1s"><wm-ve-x-mpath xlink:href="#p" /></wm-ve-x-animateMotion>') && canvas.includes('<wm-ve-x-set attributeName="x" to="1"></wm-ve-x-set>'), canvas);
// 브라우저는 알 수 없는 SVG 요소 이름을 소문자로 직렬화한다 — 복원은 표준 이름(animateMotion 등)으로 돌려준다
const fromBrowser = '<svg><circle r="1"><wm-ve-x-animate attributeName="r" to="5" dur="1s"></wm-ve-x-animate><wm-ve-x-animatemotion dur="1s"><wm-ve-x-mpath xlink:href="#p"></wm-ve-x-mpath></wm-ve-x-animatemotion><wm-ve-x-set attributeName="x" to="1"></wm-ve-x-set></circle></svg><p>본문 수정</p>';
const restored = ve.restoreFromCanvas(fromBrowser);
assert.equal(restored, '<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"></animate><animateMotion dur="1s"><mpath xlink:href="#p"></mpath></animateMotion><set attributeName="x" to="1"></set></circle></svg><p>본문 수정</p>');
const saved = ve.validateVisualSave({ original: page2, body: fromBrowser, maxBytes: 1e6 }); assert.ok(saved.html.includes('<animateMotion dur="1s"><mpath xlink:href="#p"></mpath></animateMotion>') && saved.html.includes('본문 수정'), '원래 있던 SMIL 은 그대로 저장');
assert.throws(() => ve.validateVisualSave({ original: page2, body: fromBrowser + '<svg><wm-ve-x-animate attributeName="href" to="javascript:x()"></wm-ve-x-animate></svg>', maxBytes: 1e6 }), /animate/, '캔버스 이름으로 몰래 넣어도 복원 뒤 거절');
// 5) 구성 요소 HTML 검사(core)도 SMIL 요소를 거절한다
for (const h of ['<svg><a href="#x"><animate attributeName="href" values="javascript:alert(1)"/></a></svg>', '<svg><set attributeName="href" to="#"/></svg>', '<svg><animateMotion/></svg>', '<svg><ANIMATETRANSFORM attributeName="transform"/></svg>', '<svg><svg:animate/></svg>', '<svg><mpath/></svg>', '<svg><discard/></svg>']) assert.equal(core.isSafeComponentHtml(h), false, '구성 요소 거절: ' + h);
assert.equal(core.isSafeComponentHtml('<svg viewBox="0 0 1 1"><rect width="1" height="1"/><text>set</text></svg><p>animate</p>'), true, '글자 속 단어는 요소가 아니다');
});
test('scanTags 파서 차이(저장형 XSS): HTML 의 <title>·<textarea>·<style> 은 원문(RCDATA/raw text)이지만 <svg>·<math> 안(외래 콘텐츠)에서는 자식이 요소로 해석되어 <svg><title><script>…</script></title></svg> 의 스크립트가 실행된다 — 스캐너는 외래 콘텐츠 안에서 원문 규칙을 쓰지 않고(통합 지점 foreignObject·desc·mtext 포함, fail-closed) 그 스크립트를 세어 거절하며 캔버스에서는 비활성화한다; HTML 수준의 <title> 안 글자는 그대로 글자다', () => {
const base = ve.splitDocument(PAGE).body;
const reject = (body, re, original = PAGE) => { let err = null; try { ve.validateVisualSave({ original, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body.slice(0, 120)); assert.equal(err.status, 400, err.message); assert.match(err.message, re); };
const payloads = [
'<svg><title><script>alert(1)</script></title></svg>', '<svg><title><SCRIPT>alert(1)</SCRIPT></title></svg>', '<SVG><TITLE><script>alert(1)</script></TITLE></SVG>',
'<svg><desc><script>alert(1)</script></desc></svg>', '<svg><textarea><script>alert(1)</script></textarea></svg>', '<svg><style><script>alert(1)</script></style></svg>',
'<svg><g><title><script>alert(1)</script></title></g></svg>', '<svg><foreignObject><title><script>alert(1)</script></title></foreignObject></svg>', '<svg><svg><title><script>alert(1)</script></title></svg></svg>',
'<math><mtext><title><script>alert(1)</script></title></mtext></math>', '<math><annotation-xml encoding="text/html"><title><script>alert(1)</script></title></annotation-xml></math>', '<MATH><mi><textarea><script>alert(1)</script></textarea></mi></MATH>',
'<svg><title><img src=x onerror="alert(1)"></title></svg>', '<svg><title><a href="javascript:alert(1)">x</a></title></svg>', '<svg><title><iframe src="https://e/"></iframe></title></svg>',
];
for (const p of payloads) reject(base.replace('<p>본문</p>', p), /스크립트|이벤트 속성|허용되지 않는|iframe/);
// 외래 콘텐츠 안에서 원문처럼 보이는 요소가 "닫히지 않은" 채로 와도(스캐너가 끝까지 원문으로 먹으면 뒤의 스크립트가 숨는다) 거절
reject(base.replace('<p>본문</p>', '<svg><title>x</title><title><script>alert(1)</script></svg>'), /스크립트/);
// HTML 수준: <title>·<textarea> 안의 <script> 글자는 글자다(기존 구조 테스트와 같은 규칙) — 본문에 textarea 로 넣으면 허용, 머리의 <title> 도 원문 그대로
const okBody = ve.validateVisualSave({ original: PAGE, body: base.replace('<p>본문</p>', '<p>본문</p><textarea>&lt;script&gt; 와 <script>alert(1)</script> 는 글자</textarea>'), maxBytes: 1e6 }); assert.ok(okBody.html.includes('<textarea>&lt;script&gt; 와 <script>alert(1)</script> 는 글자</textarea>'));
const orig2 = '<!doctype html><html><head><title>T <script>not code</script></title></head><body><p>본문</p></body></html>';
const ok2 = ve.validateVisualSave({ original: orig2, body: '<p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(ok2.html.includes('<title>T <script>not code</script></title>') && ok2.html.includes('본문 수정'));
// 정상 SVG(제목·설명·스타일 글자)는 그대로 허용되고, 닫힌 <svg> 뒤의 HTML <title>/<textarea> 는 다시 원문이다
const ok3 = ve.validateVisualSave({ original: PAGE, body: base.replace('<p>본문</p>', '<svg viewBox="0 0 1 1"><title>차트</title><desc>설명</desc><style>.a{fill:red}</style><rect class="a" width="1" height="1"/></svg><p>본문</p><textarea><b>x</b></textarea>'), maxBytes: 1e6 }); assert.ok(ok3.html.includes('<title>차트</title>') && ok3.html.includes('<textarea><b>x</b></textarea>'));
// 캔버스 출력: svg 안 title 의 script 는 요소로 보고 비활성화한다(실행 안 됨), HTML title 안 글자는 그대로
const canvas = ve.prepareCanvas('<html><head><title>A <script>s</script></title></head><body><svg><title><script>evil()</script></title></svg></body></html>', OPTS);
assert.ok(canvas.includes('<title>A <script>s</script></title>'), 'HTML title 원문 그대로'); assert.ok(canvas.includes('<svg><title><script type="text/x-wm-ve-disabled">evil()</script></title></svg>'), 'svg 안 script 비활성화: ' + canvas.slice(canvas.indexOf('<svg'), canvas.indexOf('</svg>') + 6));
});
test('contentHash·isHtmlPath', () => {
assert.equal(ve.contentHash('abc'), ve.contentHash('abc')); assert.notEqual(ve.contentHash('abc'), ve.contentHash('abd')); assert.equal(ve.contentHash('x').length, 64);
assert.equal(ve.isHtmlPath('index.html'), true); assert.equal(ve.isHtmlPath('en/about.htm'), true); assert.equal(ve.isHtmlPath('style.css'), false); assert.equal(ve.isHtmlPath('a.html/x.js'), false);
});
test('writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다', () => {
const os = require('os'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-atomic-')); const f = path.join(dir, 'index.html'); fs.writeFileSync(f, 'old');
ve.writeTextAtomic(f, 'new 내용');
assert.equal(fs.readFileSync(f, 'utf8'), 'new 내용'); assert.equal(fs.statSync(f).mode & 0o777, 0o644); assert.deepEqual(fs.readdirSync(dir), ['index.html'], '임시 파일 잔여 없음');
// 주입한 파일 시스템: rename 이 실패하면 임시 파일을 지우고 오류를 그대로 던진다 — 원본 경로에는 쓰지 않는다
const calls = [];
const failRename = { writeFileSync: (p, c, o) => calls.push(['write', p, o && o.mode]), renameSync: () => { throw Object.assign(new Error('ENOSPC: 공간 없음'), { code: 'ENOSPC' }); }, unlinkSync: (p) => calls.push(['unlink', p]) };
assert.throws(() => ve.writeTextAtomic(f, 'x', { fs: failRename }), /ENOSPC/);
const w = calls.find((c) => c[0] === 'write'); assert.ok(w, '임시 파일에 썼다'); assert.notEqual(w[1], f, '원본 경로에 직접 쓰지 않는다'); assert.equal(path.dirname(w[1]), dir, '같은 폴더(원자적 rename 가능)'); assert.match(path.basename(w[1]), /\.tmp$/); assert.equal(w[2], 0o644);
assert.deepEqual(calls.filter((c) => c[0] === 'unlink').map((c) => c[1]), [w[1]], '실패한 임시 파일 정리');
assert.equal(fs.readFileSync(f, 'utf8'), 'new 내용', '원본 보존');
// 쓰기 자체가 실패해도(디스크 가득·권한) 오류를 던지고 rename 은 시도하지 않는다. 임시 파일 정리의 실패는 무시한다
const calls2 = []; const failWrite = { writeFileSync: () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); }, renameSync: () => calls2.push('rename'), unlinkSync: () => { throw new Error('ENOENT'); } };
assert.throws(() => ve.writeTextAtomic(f, 'x', { fs: failWrite }), /EACCES/); assert.deepEqual(calls2, []);
assert.throws(() => ve.writeTextAtomic('', 'x'), /경로/); assert.throws(() => ve.writeTextAtomic(f, 5), /문자열/);
fs.rmSync(dir, { recursive: true, force: true });
});
// ---------- 화면 배치 계약(실제 브라우저 시각 검증에서 발견한 차단 결함의 회귀) ----------
/**
* 작은 CSS 규칙 파서: 주석을 지우고 중괄호 깊이로 규칙과 @media 블록을 나눈다. 반환: [{ media: '(max-width: 900px)'|null, selector, decls: {속성: 값} }]
* 선택자·속성 값으로 계약을 검사하기 위한 것이며 주석 문자열은 보지 않는다.
*/
function parseCss(css) {
const src = css.replace(/\/\*[\s\S]*?\*\//g, ''); const out = [];
const walk = (text, media) => {
let i = 0;
while (i < text.length) {
const open = text.indexOf('{', i); if (open < 0) break;
const head = text.slice(i, open).trim();
let depth = 1; let j = open + 1; while (j < text.length && depth > 0) { if (text[j] === '{') depth++; else if (text[j] === '}') depth--; j++; }
const inner = text.slice(open + 1, j - 1);
if (head.startsWith('@media')) walk(inner, head.replace(/^@media\s*/, ''));
else if (!head.startsWith('@')) { const decls = {}; for (const d of inner.split(';')) { const k = d.indexOf(':'); if (k > 0) decls[d.slice(0, k).trim()] = d.slice(k + 1).trim(); } for (const sel of head.split(',')) out.push({ media, selector: sel.trim().replace(/\s+/g, ' '), decls }); }
i = j;
}
};
walk(src, null); return out;
}
const CSS_RULES = parseCss(fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8'));
const rulesFor = (selector, media = null) => CSS_RULES.filter((r) => r.selector === selector && (media === null ? r.media === null : (r.media || '').includes(media)));
const lastDecl = (selector, prop, media = null) => { const rs = rulesFor(selector, media).filter((r) => prop in r.decls); return rs.length ? rs[rs.length - 1].decls[prop] : undefined; };
test('style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px', () => {
// 기본 배치(직접 편집 꺼짐)는 그대로: 레일 · 작업 칸 460px · 캔버스
assert.equal(lastDecl('.app', 'grid-template-columns'), 'var(--rail-w) var(--left-w, 460px) 1fr');
// 직접 편집 켜짐: .left 숨김 + .app 두 칸(레일 + 1fr). 작업 칸 폭(460px·--left-w)이 남아 있으면 캔버스가 좁아진다
assert.equal(lastDecl('body.ve-on .left', 'display'), 'none', '직접 편집 중 옛 작업 칸 숨김');
const cols = lastDecl('body.ve-on .app', 'grid-template-columns'); assert.ok(cols, 'body.ve-on .app 규칙');
assert.ok(/var\(--rail-w\)/.test(cols) && /1fr/.test(cols) && !/460|--left-w/.test(cols), '레일 + 전체 폭: ' + cols);
// 1280px 폭 계산: 레일 232 + 팔레트 + 캔버스 + 속성 창. 양쪽 패널을 펼친 채 캔버스 ≥ 500px
const px = (v) => parseFloat(String(v || '').match(/(\d+(?:\.\d+)?)px/) ? String(v).match(/(\d+(?:\.\d+)?)px/)[1] : 'NaN');
const rail = px(lastDecl(':root', '--rail-w')); const side = px((lastDecl('.ve-side', 'flex') || '').split(/\s+/).pop());
assert.ok(rail > 0 && side > 0, `rail=${rail} side=${side}`);
assert.ok(1280 - rail - 2 * side >= 500, `1280px 에서 캔버스 폭 ${1280 - rail - 2 * side}px (레일 ${rail} + 패널 ${side}×2)`);
// 1180px 이하(레일 204px)에서도 패널을 좁혀 캔버스 ≥ 500px 을 지킨다 — 1100px 기준
const railNarrow = px(lastDecl(':root', '--rail-w', '(max-width: 1180px)')) || rail; const sideNarrow = px((lastDecl('body.ve-on .ve-side', 'flex-basis', '(max-width: 1180px)') || '')) || side;
assert.ok(1100 - railNarrow - 2 * sideNarrow >= 500, `1100px 에서 캔버스 폭 ${1100 - railNarrow - 2 * sideNarrow}px`);
});
test('style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다', () => {
const m = '(max-width: 900px)';
assert.equal(lastDecl('.app', 'grid-template-rows', m), 'auto auto 70vh', '기본(꺼짐) 배치: 레일·작업 칸·캔버스 세 행');
const rows = lastDecl('body.ve-on .app', 'grid-template-rows', m); assert.ok(rows, '좁은 화면 body.ve-on .app 규칙');
const tracks = rows.split(/\s+(?![^()]*\))/); assert.equal(tracks.length, 2, '레일 + 캔버스 두 행: ' + rows); assert.equal(tracks[0], 'auto'); assert.ok(/fr|minmax|vh/.test(tracks[1]), '캔버스 행이 남은 높이를 차지: ' + rows);
assert.equal(lastDecl('.ve-stage', 'flex-direction', m), 'column', '무대는 세로 쌓기');
assert.equal(lastDecl('.ve-stage .frame-wrap', 'order', m), '1', '캔버스가 맨 위'); assert.equal(lastDecl('.ve-side.ve-palette', 'order', m), '2'); assert.equal(lastDecl('.ve-inspector', 'order', m), '3');
});
/**
* 캔버스 런타임(wm-ve-canvas.js)이 문서 head 에 끼워 넣는 CSS 를 소스에서 꺼내 파싱한다.
* `${MARK}` 보간은 실제 값(data-wm-ve-)으로 바꾼다. 주석이 아니라 선택자·선언을 검사하기 위한 것.
*/
function canvasRuntimeCss() {
const src = fs.readFileSync(path.join(ROOT, 'public', 'wm-ve-canvas.js'), 'utf8');
const m = src.match(/st\.textContent = `([\s\S]*?)`;/); assert.ok(m, '런타임 CSS 블록(st.textContent)');
return parseCss(m[1].replace(/\$\{MARK\}/g, 'data-wm-ve-'));
}
/**
* `hidden` 속성이 켜진 `.클래스` 요소의 최종 display 값을 계산한다(브라우저 cascade 의 단순 모형).
* 사용자 에이전트 규칙 `[hidden]{display:none}` 은 작성자 규칙(어떤 명시도든)에 밀리므로,
* 작성자 규칙 중 숨긴 요소에 맞는 것(`.c`, `.c[hidden]`; `.c:not([hidden])` 은 제외)이 display 를 정하면 그 값이 이긴다.
* 우선순위: !important > 명시도([hidden] 속성 선택자 +1) > 나중 선언. 작성자 규칙이 display 를 정하지 않으면 UA 의 none.
*/
function hiddenDisplayOf(rules, cls) {
const esc = cls.replace(/\./g, '\\.');
const matches = (sel) => new RegExp(`^${esc}(\\[hidden\\])?$`).test(sel);
let best = null;
rules.forEach((r, order) => {
if (r.media !== null || !matches(r.selector) || !('display' in r.decls)) return;
const important = /!important/.test(r.decls.display); const spec = /\[hidden\]/.test(r.selector) ? 1 : 0;
const cand = { value: r.decls.display.replace(/\s*!important/, '').trim(), key: [important ? 1 : 0, spec, order] };
const gt = (a, b) => { for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] > b[i]; } return false; }; // 사전식 비교
if (!best || gt(cand.key, best.key)) best = cand;
});
return best ? best.value : 'none';
}
test('wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다', () => {
const rules = canvasRuntimeCss();
// 선택 도구 줄은 renderOverlay() 가 hidden=false 로 바꾸기 전까지 보이면 안 된다.
// `.wm-ve-tools{display:flex}` 같은 작성자 규칙은 UA 의 `[hidden]{display:none}` 을 덮어써 선택 전에도 어두운 도구 줄이 떠 버린다.
assert.equal(hiddenDisplayOf(rules, '.wm-ve-tools'), 'none', 'hidden 인 .wm-ve-tools 의 최종 display');
assert.equal(hiddenDisplayOf(rules, '.wm-ve-drop'), 'none', 'hidden 인 .wm-ve-drop 의 최종 display');
// 보일 때(hidden 아님)는 도구 줄이 가로 배치(flex)여야 버튼이 한 줄로 놓인다
const shown = rules.filter((r) => r.media === null && /^\.wm-ve-tools(:not\(\[hidden\]\))?$/.test(r.selector) && 'display' in r.decls);
assert.ok(shown.some((r) => /^flex$/.test(r.decls.display)), '보이는 .wm-ve-tools 는 display:flex');
});
/** 가짜 DOM 요소: 화면 코드가 쓰는 최소 표면(classList·속성·innerHTML·querySelectorAll·이벤트) */
function fakeEl(id) {
const cls = new Set(); const attrs = {};
return { id, classList: { add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)), toggle: (c, on) => { (on === undefined ? !cls.has(c) : on) ? cls.add(c) : cls.delete(c); }, contains: (c) => cls.has(c) }, setAttribute: (k, v) => { attrs[k] = String(v); }, getAttribute: (k) => (k in attrs ? attrs[k] : null), removeAttribute: (k) => { delete attrs[k]; }, hasAttribute: (k) => k in attrs,
textContent: '', innerHTML: '', className: '', disabled: false, value: '', hidden: false, style: {}, childElementCount: 0, children: [], dataset: {}, contentWindow: {}, _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, appendChild() { this.childElementCount++; }, querySelectorAll: () => [], focus() {}, click() {},
fire(t, ev) { (this._ev[t] || []).forEach((fn) => fn(ev)); return ev; }, _captured: null, setPointerCapture(id) { this._captured = id; }, releasePointerCapture() { this._captured = null; }, closest() { return null; }, clientLeft: 0, clientTop: 0, getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }; } };
}
/** 빌더 쪽 제어(visual-editor-ui.js)를 가짜 DOM 위에서 실행해 VisualEditor 객체와 document 를 돌려준다 */
function loadUi(apiResponse) {
const els = {}; const $ = (id) => (els[id] || (els[id] = fakeEl(id))); const created = [];
const document = { body: fakeEl('body'), readyState: 'complete', getElementById: $, createElement: (t) => { const e = fakeEl(t); e.tagName = t; created.push(e); return e; }, _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, fire(t, ev) { (this._ev[t] || []).forEach((fn) => fn(ev)); return ev; }, activeElement: null, querySelectorAll: () => [], created };
const ctx = { document, console, setTimeout, clearTimeout, confirm: () => (ctx.answer === undefined ? true : ctx.answer), alert: (m) => { throw new Error('alert: ' + m); }, localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
$, T: (s) => s, calls: [], api: async (p, o) => { ctx.calls.push({ path: p, method: (o && o.method) || 'GET' }); return apiResponse; }, current: { id: 'a'.repeat(32), files: ['index.html'], url: 'http://h/a/' }, ed: { open: false, dirty: false }, setEditorOpen() {}, renderGit() {}, loadFrame() { ctx.loaded = (ctx.loaded || 0) + 1; }, logLine() {}, pushMsg() {} };
ctx.window = ctx; ctx._winEv = {}; ctx.window.addEventListener = (t, fn) => { (ctx._winEv[t] || (ctx._winEv[t] = [])).push(fn); }; ctx.window.fire = (t, ev) => { (ctx._winEv[t] || []).forEach((fn) => fn(ev)); return ev; }; ctx.window.matchMedia = () => ({ matches: false });
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-ui.js'), 'utf8'), ctx);
return { ve: ctx.VisualEditor, document, els, ctx };
}
/**
* 가짜 MessageChannel: 두 포트가 서로 짝이고 postMessage 는 구조 복제(JSON) 뒤 상대 onmessage 를 바로(동기) 부른다.
* 브라우저의 MessagePort 처럼 message 이벤트에 source/origin 이 없다(포트는 만든 문서에 묶인다). close() 뒤에는 어느 쪽도 전달되지 않는다.
*/
function fakeChannel() {
const mk = () => ({ onmessage: null, peer: null, closed: false, started: false, postMessage(m) { const p = this.peer; if (!p || this.closed || p.closed) return; const data = JSON.parse(JSON.stringify(m)); if (typeof p.onmessage === 'function') p.onmessage({ data, origin: '', source: null, ports: [] }); }, start() { this.started = true; }, close() { this.closed = true; } });
const port1 = mk(); const port2 = mk(); port1.peer = port2; port2.peer = port1; return { port1, port2 };
}
/** 캔버스가 보낸 것처럼 ready(+포트) 창 메시지를 빌더 쪽에 쏘아 연결한다. sent = 빌더가 포트로 보낸 명령, fromCanvas = 캔버스가 포트로 보내는 이벤트 */
function connectCanvas({ document, ctx }, session) {
const frameEl = document.getElementById('frame'); const ch = fakeChannel(); const sent = []; ch.port1.onmessage = (e) => sent.push(e.data);
ctx.window.fire('message', { data: core.makeMessage(session, 'ready', { title: 't' }), source: frameEl.contentWindow, origin: 'null', ports: [ch.port2] });
return { sent, ch, fromCanvas: (type, payload) => ch.port1.postMessage(core.makeMessage(session, type, payload)) };
}
test('visual-editor-ui.js 연결 규약: 첫 ready 한 번으로 MessagePort 에 연결해 모든 명령을 포트로만 보내고(창 메시지 없음), 두 번째 ready(바뀐 문서)·창으로 온 changed/hotkey 는 보낸 창·출처·세션이 맞아도 무시하며, 끝내면 포트를 닫는다', async () => {
const SESSION = 'ab'.repeat(16); const ui = loadUi({ session: SESSION, hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
const { ve, document, ctx } = ui; const frameEl = document.getElementById('frame'); const winSent = []; frameEl.contentWindow.postMessage = (m) => winSent.push(m);
await ve.open('index.html');
const viaWindow = (type, payload) => ctx.window.fire('message', { data: core.makeMessage(SESSION, type, payload), source: frameEl.contentWindow, origin: 'null' });
// 연결 전: 포트 없는 ready·changed 창 메시지는 연결도 변경도 만들지 않는다
viaWindow('ready', {}); viaWindow('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), false); assert.equal(winSent.length, 0);
// 연결: ready + 포트 → init 은 포트로, 창(targetOrigin '*')으로는 아무것도 보내지 않는다
const c = connectCanvas(ui, SESSION);
assert.deepEqual(c.sent.map((m) => m.type), ['init']); assert.equal(c.sent[0].session, SESSION); assert.equal(winSent.length, 0, '세션이 든 명령을 창으로 보내지 않는다');
c.fromCanvas('body', { body: '<p>x</p>' }); c.fromCanvas('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), true, '포트로 온 변경은 처리');
// 두 번째 ready(iframe 안 문서가 바뀜 — 같은 contentWindow·출처 null·세션까지 맞음) → 연결을 바꾸지 않는다
const ch2 = fakeChannel(); const sent2 = []; ch2.port1.onmessage = (e) => sent2.push(e.data);
ctx.window.fire('message', { data: core.makeMessage(SESSION, 'ready', {}), source: frameEl.contentWindow, origin: 'null', ports: [ch2.port2] });
ve.undo(); assert.equal(c.sent.at(-1).type, 'applyHtml', '명령은 첫 포트로'); assert.equal(sent2.length, 0, '새 포트로는 아무것도 가지 않는다'); assert.equal(winSent.length, 0);
// 창으로 온 changed·hotkey(save)·body 는 무시: 기록·저장 호출 없음
const h0 = ve.state().save; const calls0 = ctx.calls.length; viaWindow('changed', { body: '<p>z</p>' }); viaWindow('hotkey', { key: 'save' }); viaWindow('body', { body: '<p>q</p>' });
assert.equal(ve.state().save, h0); assert.equal(ctx.calls.length, calls0, '창 메시지로 저장(PUT)이 일어나지 않는다'); assert.equal(c.sent.filter((m) => m.type === 'applyHtml').length, 1);
// 포트로 온 hotkey 는 처리된다(정상 런타임은 계속 동작)
c.fromCanvas('hotkey', { key: 'undo' }); assert.ok(c.sent.filter((m) => m.type === 'applyHtml').length >= 1);
// 세션이 다른 포트 메시지는 무시
c.ch.port1.postMessage(core.makeMessage('other', 'changed', { body: '<p>w</p>' })); assert.notEqual(ve.state().save, 'dirty-from-other');
// 끝내기 → 포트 닫힘, 이후 포트 메시지는 아무 효과 없음; 다시 열면 새 ready 가 다시 한 번 받아들여진다
ve.close(true); assert.equal(c.ch.port2.closed, true, '끝내면 포트를 닫는다'); c.fromCanvas('changed', { body: '<p>v</p>' }); assert.equal(ve.isDirty(), false);
await ve.open('index.html'); const c2 = connectCanvas(ui, SESSION); assert.deepEqual(c2.sent.map((m) => m.type), ['init']); assert.equal(winSent.length, 0);
ve.close(true);
});
test('visual-editor-ui.js onSiteUpdated: 저장하지 않은 변경이 있으면 다른 사이트·사라진 페이지·HTML 없는 갱신에도 묻지 않고 끝내지 않는다 — 취소하면 편집기·사이트를 그대로 두고, 승낙하면 끝낸다', async () => {
const SESSION = 'ab'.repeat(16); const ui = loadUi({ session: SESSION, hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
const { ve, ctx } = ui; await ve.open('index.html'); const c = connectCanvas(ui, SESSION); c.fromCanvas('body', { body: '<p>x</p>' }); c.fromCanvas('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), true);
ctx.answer = false; let asked = 0; ctx.confirm = () => { asked++; return ctx.answer; };
ve.onSiteUpdated({ id: 'b'.repeat(32), files: ['index.html'] }); assert.equal(ve.isActive(), true, '다른 사이트 갱신: 취소하면 유지'); assert.equal(ve.state().siteId, 'a'.repeat(32)); assert.equal(asked, 1);
ve.onSiteUpdated({ id: 'a'.repeat(32), files: [] }); assert.equal(ve.isActive(), true, '페이지가 사라진 갱신: 취소하면 유지'); assert.equal(ve.isDirty(), true); assert.equal(asked, 2);
ve.onSiteUpdated(null); assert.equal(ve.isActive(), true); assert.equal(asked, 3);
ctx.answer = true; ve.onSiteUpdated({ id: 'b'.repeat(32), files: [] }); assert.equal(ve.isActive(), false, '승낙하면 끝낸다'); assert.equal(asked, 4);
// 변경이 없으면 묻지 않고 끝낸다(잃을 것이 없다)
await ve.open('index.html'); const c2 = connectCanvas(ui, SESSION); c2.fromCanvas('body', { body: '<p>x</p>' }); asked = 0; ve.onSiteUpdated({ id: 'b'.repeat(32), files: [] }); assert.equal(ve.isActive(), false); assert.equal(asked, 0);
});
test('visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다', async () => {
const { ve, document, els, ctx } = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
assert.ok(ve && typeof ve.open === 'function');
assert.equal(document.body.classList.contains('ve-on'), false);
await ve.open('index.html');
assert.equal(ve.isActive(), true); assert.equal(document.body.classList.contains('ve-on'), true, '직접 편집 중 body.ve-on — CSS 가 작업 칸을 숨기고 .app 을 다시 배치하는 근거');
for (const id of ['veBar', 'vePalette', 'veInspector']) assert.equal(els[id].classList.contains('hidden'), false, id + ' 보임');
assert.equal(els.btnVisual.getAttribute('aria-pressed'), 'true'); assert.equal(els.frame.srcdoc.includes('<p>x</p>'), true, '캔버스 문서가 iframe 에 들어감');
assert.equal(ve.state().siteId, ctx.current.id); assert.equal(ve.state().file, 'index.html');
assert.equal(ve.close(false), true);
assert.equal(ve.isActive(), false); assert.equal(document.body.classList.contains('ve-on'), false, '끝내면 body.ve-on 제거 → 옛 배치 복원');
for (const id of ['veBar', 'vePalette', 'veInspector']) assert.equal(els[id].classList.contains('hidden'), true, id + ' 숨김');
assert.equal(els.btnVisual.getAttribute('aria-pressed'), 'false'); assert.equal(els.frame.getAttribute('srcdoc'), null); assert.ok(ctx.loaded >= 1, '미리보기를 저장된 파일로 다시 읽는다');
// 다시 켜고 강제 종료(사이트 전환)해도 같은 결과
await ve.open('index.html'); assert.equal(document.body.classList.contains('ve-on'), true); ve.close(true); assert.equal(document.body.classList.contains('ve-on'), false);
});
// ---------- 공용 핵심(public/visual-editor-core.js) ----------
test('core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다', () => {
const dt = (data) => ({ types: Object.keys(data), getData: (k) => (k in data ? data[k] : '') });
assert.equal(core.COMPONENT_MIME, 'text/wm-ve-component');
assert.equal(core.componentDragText('paragraph'), 'wm-ve-component:paragraph');
// 끌기 인식: 전용 MIME 또는 text/plain 이 있으면(dragover 때는 getData 를 읽을 수 없으므로 종류로만 판단)
assert.equal(core.isComponentDrag(['text/wm-ve-component']), true); assert.equal(core.isComponentDrag(['text/plain']), true);
assert.equal(core.isComponentDrag(['Files']), false); assert.equal(core.isComponentDrag(['text/html', 'text/uri-list']), false); assert.equal(core.isComponentDrag(undefined), false);
// 전용 MIME 우선
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'heading', 'text/plain': 'wm-ve-component:paragraph' })), 'heading');
// 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 복원
assert.equal(core.dragComponentId(dt({ 'text/plain': 'wm-ve-component:paragraph' })), 'paragraph');
for (const c of core.COMPONENTS) assert.equal(core.dragComponentId(dt({ 'text/plain': core.componentDragText(c.id) })), c.id, c.id);
// 거절: 임의 글·주소·HTML, 모르는 id, 정확히 일치하지 않는 값(공백·대문자·접두 변형), 전용 MIME 의 모르는 id
for (const bad of ['https://example.com/', '<img src=x onerror=alert(1)>', 'paragraph', 'wm-ve-component:evil', 'wm-ve-component:paragraph ', ' wm-ve-component:paragraph', 'wm-ve-component:Paragraph', 'WM-VE-COMPONENT:paragraph', 'wm-ve-component:', 'xwm-ve-component:paragraph'])
assert.equal(core.dragComponentId(dt({ 'text/plain': bad })), null, JSON.stringify(bad));
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'evil' })), null);
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'evil', 'text/plain': 'wm-ve-component:paragraph' })), null, '전용 MIME 이 있으면 그 값만 믿는다');
assert.equal(core.dragComponentId(null), null); assert.equal(core.dragComponentId({}), null);
assert.equal(core.dragComponentId({ types: ['text/plain'], getData() { throw new Error('denied'); } }), null, 'getData 예외는 거절로');
});
test('visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류)', async () => {
const { ve, document } = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
await ve.open('index.html');
const items = document.created.filter((e) => e.tagName === 'li' && e.className === 've-comp');
assert.equal(items.length, core.COMPONENTS.length, '팔레트 항목 수');
items.forEach((li, i) => {
assert.ok(li._ev.dragstart && li._ev.dragstart.length, 'dragstart 처리기');
const data = {}; const e = { dataTransfer: { setData: (k, v) => { data[k] = String(v); }, effectAllowed: '' } };
li._ev.dragstart.forEach((fn) => fn(e));
const id = core.COMPONENTS[i].id;
assert.equal(data['text/wm-ve-component'], id, '전용 MIME 유지');
assert.equal(data['text/plain'], core.componentDragText(id), 'text/plain 은 이름 공간 + 검증된 id');
assert.equal(e.dataTransfer.effectAllowed, 'copy');
});
});
/**
* 캔버스 런타임(wm-ve-canvas.js)을 실행할 수 있는 작은 가짜 DOM.
* 런타임이 쓰는 표면만 구현한다: 요소 트리(appendChild·before·after·insertBefore·remove·cloneNode), 속성·classList·style,
* innerHTML/outerHTML(간단한 HTML 파서·직렬화), querySelectorAll/closest/matches(태그·[속성]·[속성=값]·쉼표 목록), getBoundingClientRect(테스트가 지정),
* document(createElement·template.content·elementFromPoint·addEventListener), window.parent.postMessage(메시지 기록).
*/
function canvasDom(bodyHtml) {
const VOID = new Set(['img', 'hr', 'br', 'input', 'meta', 'link', 'base', 'source', 'area']);
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
class FNode {
constructor(tag) { this.nodeType = 1; this.tagName = tag.toUpperCase(); this._attrs = new Map(); this._kids = []; this._text = ''; this.parentElement = null; this.style = {}; this._ev = {}; this.hidden = false; this._rect = null;
const self = this; const cls = () => (self.getAttribute('class') || '').split(/\s+/).filter(Boolean); const set = (a) => { if (a.length) self.setAttribute('class', a.join(' ')); else self.removeAttribute('class'); };
this.classList = { add: (...c) => set([...new Set([...cls(), ...c])]), remove: (...c) => set(cls().filter((x) => !c.includes(x))), toggle: (c, on) => { const has = cls().includes(c); const want = on === undefined ? !has : !!on; if (want && !has) set([...cls(), c]); if (!want && has) set(cls().filter((x) => x !== c)); return want; }, contains: (c) => cls().includes(c), get length() { return cls().length; }, [Symbol.iterator]() { return cls()[Symbol.iterator](); } }; }
get className() { return this.getAttribute('class') || ''; } set className(v) { if (v) this.setAttribute('class', v); else this.removeAttribute('class'); }
get id() { return this.getAttribute('id') || ''; }
get attributes() { return [...this._attrs].map(([name, value]) => ({ name, value })); }
setAttribute(k, v) { this._attrs.set(k, String(v)); } getAttribute(k) { return this._attrs.has(k) ? this._attrs.get(k) : null; } hasAttribute(k) { return this._attrs.has(k); } removeAttribute(k) { this._attrs.delete(k); }
get children() { return [...this._kids]; } get firstElementChild() { return this._kids[0] || null; }
get nextElementSibling() { const p = this.parentElement; if (!p) return null; return p._kids[p._kids.indexOf(this) + 1] || null; }
get textContent() { return this._text + this._kids.map((k) => k.textContent).join(''); } set textContent(v) { this._text = String(v); this._kids = []; }
get innerHTML() { return this._text + this._kids.map((k) => k.outerHTML).join(''); } set innerHTML(v) { this._text = ''; this._kids = []; parse(String(v)).forEach((k) => this.appendChild(k)); }
get outerHTML() { const t = this.tagName.toLowerCase(); const a = [...this._attrs].map(([k, v]) => ` ${k}="${esc(v)}"`).join(''); return VOID.has(t) ? `<${t}${a}>` : `<${t}${a}>${this.innerHTML}</${t}>`; }
appendChild(n) { if (n.parentElement) n.remove(); n.parentElement = this; this._kids.push(n); return n; }
insertBefore(n, ref) { if (!ref) return this.appendChild(n); if (n.parentElement) n.remove(); const i = this._kids.indexOf(ref); n.parentElement = this; this._kids.splice(i < 0 ? this._kids.length : i, 0, n); return n; }
before(n) { this.parentElement.insertBefore(n, this); } after(n) { this.parentElement.insertBefore(n, this.nextElementSibling); }
remove() { const p = this.parentElement; if (!p) return; p._kids.splice(p._kids.indexOf(this), 1); this.parentElement = null; }
cloneNode(deep) { const c = new FNode(this.tagName); for (const [k, v] of this._attrs) c._attrs.set(k, v); c._text = this._text; c._rect = this._rect; if (deep) this._kids.forEach((k) => c.appendChild(k.cloneNode(true))); return c; }
contains(n) { return n === this || this._kids.some((k) => k.contains(n)); }
_all() { return this._kids.flatMap((k) => [k, ...k._all()]); }
matches(sel) { return String(sel).split(',').some((s) => { s = s.trim(); const m = s.match(/^([a-zA-Z*][\w-]*)?((?:\[[^\]]+\])*)$/); if (!m) return false; if (m[1] && m[1] !== '*' && m[1].toUpperCase() !== this.tagName) return false; for (const a of (m[2] || '').match(/\[[^\]]+\]/g) || []) { const am = a.match(/^\[([^=\]]+)(?:=["']?([^"'\]]*)["']?)?\]$/); if (!am) return false; if (!this.hasAttribute(am[1])) return false; if (am[2] !== undefined && this.getAttribute(am[1]) !== am[2]) return false; } return true; }); }
closest(sel) { let c = this; while (c && c.nodeType === 1) { if (c.matches(sel)) return c; c = c.parentElement; } return null; }
querySelectorAll(sel) { return this._all().filter((n) => n.matches(sel)); } querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
getBoundingClientRect() { return this._rect || { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; }
scrollIntoView() {} focus() {} addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); } removeEventListener() {}
setPointerCapture(id) { this._captured = id; } releasePointerCapture() { this._captured = null; }
}
function parse(html) {
const root = new FNode('#root'); let cur = root;
const re = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g; let m;
while ((m = re.exec(html))) {
if (m[1]) { if (cur !== root) cur = cur.parentElement || root; }
else if (m[2]) { const el = new FNode(m[2]); const ar = /([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; let a; while ((a = ar.exec(m[3] || ''))) el.setAttribute(a[1], a[2] !== undefined ? a[2] : a[3] !== undefined ? a[3] : a[4] !== undefined ? a[4] : ''); cur.appendChild(el); if (!m[4] && !VOID.has(m[2].toLowerCase())) cur = el; }
else if (m[5] && m[5].trim()) cur._text += m[5].replace(/&quot;/g, '"').replace(/&amp;/g, '&');
}
return [...root._kids]; // 복사본: appendChild 가 원래 부모(root)에서 떼어 내며 목록을 바꾸므로
}
const doc = { readyState: 'complete', title: '테스트', _ev: {}, addEventListener(t, fn) { (doc._ev[t] || (doc._ev[t] = [])).push(fn); }, removeEventListener() {}, execCommand() {},
createElement: (t) => { const n = new FNode(t); if (t === 'template') n.content = n; return n; }, elementFromPoint: () => null, querySelectorAll: (s) => doc.body.querySelectorAll(s) };
doc.documentElement = new FNode('html'); doc.head = new FNode('head'); doc.body = new FNode('body'); doc.documentElement.appendChild(doc.head); doc.documentElement.appendChild(doc.body); doc.body.innerHTML = bodyHtml;
return doc;
}
/** 캔버스 런타임을 가짜 DOM 위에서 부트하고, 이벤트를 쏘는 fire()·부모가 받은 메시지·본문 자식 목록을 돌려준다 */
function loadCanvas(bodyHtml) {
const doc = canvasDom(bodyHtml); const messages = []; const transfers = []; let parentPort = null;
const ctx = { document: doc, console, setTimeout, clearTimeout, getComputedStyle: () => ({ display: 'block', textAlign: 'start' }), __WM_VE__: { session: 'sess-1234', parentOrigin: 'http://h' }, scrollX: 0, scrollY: 0, innerWidth: 800, innerHeight: 600, _ev: {},
addEventListener(t, fn) { (ctx._ev[t] || (ctx._ev[t] = [])).push(fn); }, removeEventListener(t, fn) { ctx._ev[t] = (ctx._ev[t] || []).filter((f) => f !== fn); }, MessageChannel: function () { const ch = fakeChannel(); this.port1 = ch.port1; this.port2 = ch.port2; } };
ctx.window = ctx;
/** 부모 창: ready 와 함께 넘어온 포트를 받아 그 뒤 캔버스가 포트로 보내는 모든 이벤트를 messages 에 쌓는다 */
ctx.parent = { postMessage: (m, origin, transfer) => { messages.push(m); transfers.push({ origin, ports: transfer || [] }); if (transfer && transfer[0]) { parentPort = transfer[0]; parentPort.onmessage = (e) => messages.push(e.data); } } };
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'wm-ve-canvas.js'), 'utf8'), ctx, { filename: 'wm-ve-canvas.js' });
const fire = (type, ev) => { ev.defaultPrevented = false; ev.preventDefault = () => { ev.defaultPrevented = true; }; ev.target = ev.target || doc.documentElement; (doc._ev[type] || []).forEach((fn) => fn(ev)); return ev; };
const fireWin = (type, ev) => { ev = ev || {}; ev.preventDefault = () => { ev.defaultPrevented = true; }; (ctx._ev[type] || []).slice().forEach((fn) => fn(ev)); return ev; };
const fireOn = (el, type, ev) => { ev = ev || {}; ev.preventDefault = () => { ev.defaultPrevented = true; }; ev.target = ev.target || el; (el._ev[type] || []).forEach((fn) => fn(ev)); return ev; };
/** 부모가 연결된 포트로 명령을 보낸다(세션 일치). over.data 로 봉투를 통째로 바꿀 수 있다 */
const fromParent = (type, payload, over) => { const data = over && over.data ? over.data : core.makeMessage('sess-1234', type, payload); if (!parentPort) throw new Error('포트가 연결되지 않음'); parentPort.postMessage(data); return data; };
/** 창(window.postMessage) 으로 온 명령 — 보낸 창·출처·세션이 모두 맞는, 예전 규약의 메시지. 연결 규약에서는 처리되지 않아야 한다 */
const fromWindow = (type, payload) => { const ev = { data: core.makeMessage('sess-1234', type, payload), source: ctx.parent, origin: 'http://h' }; (ctx._ev.message || []).forEach((fn) => fn(ev)); return ev; };
const kids = () => doc.body.children.filter((c) => !c.hasAttribute('data-wm-ve-ui'));
const dropBar = () => doc.body.children.find((c) => c.classList.contains('wm-ve-drop'));
const tools = () => doc.body.children.find((c) => c.classList.contains('wm-ve-tools'));
return { doc, ctx, messages, transfers, port: () => parentPort, fire, fireWin, fireOn, fromParent, fromWindow, kids, dropBar, tools };
}
test('wm-ve-canvas.js 연결 규약: ready 와 함께 MessagePort 를 부모 출처로 넘기고 그 뒤 명령은 포트로만 받는다 — 창으로 온 명령은 보낸 창·출처·세션이 맞아도 처리하지 않고, 세션이 다른 포트 메시지도 무시한다', () => {
const { messages, transfers, port, fromParent, fromWindow, kids } = loadCanvas('<h1>제목</h1>');
const ready = messages.find((m) => m.type === 'ready'); assert.ok(ready, 'ready'); assert.equal(transfers[0].origin, 'http://h', 'ready 는 부모 출처로만'); assert.ok(port(), 'ready 와 함께 포트가 넘어온다');
const n0 = kids().length; const m0 = messages.length;
fromWindow('insert', { component: 'paragraph', where: 'end' }); fromWindow('requestBody', {});
assert.equal(kids().length, n0, '창 명령으로는 아무것도 넣지 않는다'); assert.equal(messages.slice(m0).length, 0, '창 명령에 응답(body)하지 않는다');
fromParent('requestBody', {}); assert.equal(messages.at(-1).type, 'body', '포트 명령에는 응답'); assert.equal(messages.at(-1).session, 'sess-1234');
fromParent('insert', { component: 'paragraph', where: 'end' }); assert.equal(kids().length, n0 + 1, '포트 명령은 처리'); assert.equal(messages.at(-1).type, 'changed');
const m1 = messages.length; fromParent('insert', { component: 'paragraph', where: 'end' }, { data: core.makeMessage('other', 'insert', { component: 'paragraph', where: 'end' }) }); fromParent('x', {}, { data: { wmve: 1, session: 'sess-1234', type: 'evil', payload: {} } }); fromParent('x', {}, { data: 'insert' });
assert.equal(kids().length, n0 + 1, '세션·종류·형식이 틀린 포트 메시지는 무시'); assert.equal(messages.slice(m1).length, 0);
});
test('wm-ve-canvas.js: 캔버스 안의 이동·제출 활성화(a·area href 클릭, 제출 버튼, 폼 submit)는 편집·미리보기 모드 모두에서 막힌다', () => {
const { doc, fire, fromParent } = loadCanvas('<map name="m"><area href="x.html" shape="rect" coords="0,0,1,1"></map><a href="y.html">링크</a><form action="z.html"><button type="submit">보내기</button><input type="submit" value="s"></form>');
const area = doc.body.querySelector('area'); const a = doc.body.querySelector('a'); const btn = doc.body.querySelector('button'); const inp = doc.body.querySelector('input'); const form = doc.body.querySelector('form');
for (const mode of ['edit', 'preview']) {
fromParent('setMode', { mode });
for (const el of [area, a, btn, inp]) assert.equal(fire('click', { target: el }).defaultPrevented, true, `${mode}: ${el.tagName} 클릭 기본 동작 차단`);
// 가운데 버튼(auxclick)의 기본 동작은 "새 탭에서 열기" — 실제 Chrome 에서 sandbox(allow-popups 없음) 프레임의 링크 가운데 클릭이 새 페이지를 만든 것을 게이트가 관찰했다 → auxclick 도 막는다
for (const el of [area, a]) assert.equal(fire('auxclick', { target: el, button: 1 }).defaultPrevented, true, `${mode}: ${el.tagName} 가운데 클릭(auxclick) 차단`);
assert.equal(fire('submit', { target: form }).defaultPrevented, true, `${mode}: submit 차단`);
}
// 활성화 요소가 아닌 곳의 auxclick 은 건드리지 않는다
assert.equal(fire('auxclick', { target: doc.body, button: 1 }).defaultPrevented, false);
});
test('prepareCanvas: 이동 활성화 방어 — meta refresh 는 보존 속성으로 바뀌고, CSP 가 form-action 을 막으며, 캔버스 base 는 target=_blank(sandbox 에서 새 창 이동은 열리지 않는다)', () => {
const html = ve.prepareCanvas('<!doctype html><html><head><meta http-equiv="refresh" content="0;url=https://e/"></head><body><a href="x.html">x</a></body></html>', OPTS);
assert.ok(!/<meta http-equiv="refresh"/.test(html) && html.includes('data-wm-ve-http-equiv="refresh"'), 'meta refresh 비활성화');
assert.match(html, /Content-Security-Policy" content="[^"]*form-action 'none'/);
assert.match(html, /<base href="http:\/\/h\/abc\/" target="_blank" data-wm-ve-ui>/);
});
test('wm-ve-canvas.js duplicate: 복제본은 뿌리뿐 아니라 모든 자손의 id 도 지우고, 원본의 id 는 그대로 둔다', () => {
const { doc, fromParent, kids, messages } = loadCanvas('<section id="s"><h2 id="t">x</h2><p>y<span id="u">z</span></p></section>');
fromParent('select', { path: [0] }); fromParent('duplicate', {});
assert.equal(kids().length, 2); const [orig, copy] = kids();
assert.equal(orig.getAttribute('id'), 's'); assert.equal(orig.querySelector('h2').getAttribute('id'), 't'); assert.equal(orig.querySelector('span').getAttribute('id'), 'u', '원본 그대로');
assert.equal(copy.hasAttribute('id'), false); assert.equal(copy.querySelectorAll('[id]').length, 0, '복제본 자손에 id 없음');
assert.equal(doc.body.querySelectorAll('[id=t]').length, 1); assert.equal(messages.at(-1).type, 'changed'); assert.ok(!/id="t".*id="t"/s.test(messages.at(-1).payload.body));
});
test('wm-ve-canvas.js 기존 블록 끌기: 손잡이 pointerdown 이 포인터를 캡처하고, pointercancel·창 blur·Escape 가 끌기 상태(dragging 클래스·놓기 표시·창 리스너·캡처)를 모두 정리하며 이후 pointerup 은 옮기지 않는다 — 키보드 Alt+↓ 는 그대로', () => {
const { doc, ctx, messages, fire, fireWin, fireOn, fromParent, kids, dropBar, tools } = loadCanvas('<h1>제목</h1><p>본문</p>');
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
fromParent('select', { path: [0] }); const grip = tools().querySelector('[class="wm-ve-grip"]'); assert.ok(grip, '끌기 손잡이');
const winCount = (t) => (ctx._ev[t] || []).length; const base = { pointermove: winCount('pointermove'), pointerup: winCount('pointerup'), pointercancel: winCount('pointercancel'), blur: winCount('blur') };
const start = () => { fireOn(grip, 'pointerdown', { pointerId: 3, clientX: 5, clientY: 5 }); assert.equal(grip._captured, 3, 'setPointerCapture'); assert.equal(h1.classList.contains('wm-ve-dragging'), true); fireWin('pointermove', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(dropBar().hidden, false, '놓기 표시'); };
const clean = (label) => { assert.equal(h1.classList.contains('wm-ve-dragging'), false, label + ': dragging 클래스 제거'); assert.equal(dropBar().hidden, true, label + ': 놓기 표시 숨김'); assert.equal(grip._captured, null, label + ': 캡처 해제'); for (const t of Object.keys(base)) assert.equal(winCount(t), base[t], label + ': 창 리스너 ' + t + ' 정리'); };
const order = () => kids().map((k) => k.tagName).join(',');
// 1) pointercancel
start(); const m1 = messages.length; fireWin('pointercancel', { pointerId: 3 }); clean('pointercancel'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P', '취소 뒤 up 은 옮기지 않는다'); assert.equal(messages.slice(m1).filter((m) => m.type === 'changed').length, 0);
// 2) 창 blur(iframe 밖으로 포커스가 나감)
start(); fireWin('blur', {}); clean('blur'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P');
// 3) Escape
start(); fire('keydown', { key: 'Escape', ctrlKey: false, metaKey: false, altKey: false }); clean('Escape'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P');
// 4) 정상 놓기는 여전히 옮긴다, 뒤처리 동일
start(); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'P,H1', '정상 끌어 옮기기'); clean('pointerup'); assert.equal(messages.at(-1).type, 'changed');
// 5) 키보드 대안 보존
fire('keydown', { key: 'ArrowUp', altKey: true, ctrlKey: false, metaKey: false }); assert.equal(order(), 'H1,P', 'Alt+↑');
});
test('wm-ve-canvas.js applyProps·core.safeUrlInput: 엔티티·제어 문자로 감춘 javascript: 주소는 속성 창 값으로도 들어가지 않는다', () => {
for (const bad of ['javascript&colon;alert(1)', 'java&colon;script:alert(1)', '&#x6a;avascript:alert(1)', 'java\u0000script:alert(1)', '&NewLine;javascript:alert(1)', 'java&bogus;script:alert(1)']) { const r = core.safeUrlInput(bad, 'link'); assert.equal(r.ok, false, bad); }
for (const good of ['https://e/?a=1&amp;b=2', 'about.html?x&colon;y', '#top', 'mailto:a@b.c']) assert.equal(core.safeUrlInput(good, 'link').ok, true, good);
const { doc, fromParent, messages } = loadCanvas('<a href="about.html">x</a>');
fromParent('select', { path: [0] }); const m0 = messages.length; fromParent('setProps', { props: { href: 'javascript&colon;alert(1)' } });
assert.equal(doc.body.querySelector('a').getAttribute('href'), 'about.html', 'href 불변'); assert.equal(messages.slice(m0).filter((m) => m.type === 'error').length, 1); assert.equal(messages.slice(m0).filter((m) => m.type === 'changed').length, 0);
});
test('core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증', () => {
for (const t of ['paletteDragAt', 'paletteDragCancel', 'paletteDropAt']) assert.ok(core.MESSAGE_TYPES.toCanvas.includes(t), t + ' 는 부모→캔버스 종류');
assert.ok(core.POINTER_DRAG_THRESHOLD >= 3 && core.POINTER_DRAG_THRESHOLD <= 10, '끌기 시작 문턱(px)');
// iframe 의 화면 사각형(left 300, top 100, 폭 600, 높이 400) + 테두리 12px(모바일 미리보기 틀) → 안쪽 좌표
const rect = { left: 300, top: 100, width: 624, height: 424, right: 924, bottom: 524 }; const box = { left: 12, top: 12, width: 600, height: 400 };
assert.deepEqual(core.pointInFrame(412, 212, rect, box), { inside: true, x: 100, y: 100 });
assert.deepEqual(core.pointInFrame(312, 112, rect, box), { inside: true, x: 0, y: 0 }, '안쪽 왼쪽 위 모서리');
assert.equal(core.pointInFrame(305, 150, rect, box).inside, false, '테두리 위는 바깥');
assert.equal(core.pointInFrame(10, 10, rect, box).inside, false); assert.equal(core.pointInFrame(913, 300, rect, box).inside, false, '오른쪽 안쪽 경계 밖');
assert.deepEqual(core.pointInFrame(350, 150, { left: 300, top: 100, width: 600, height: 400 }), { inside: true, x: 50, y: 50 }, 'box 생략 = 테두리 0');
// 캔버스 쪽 검증: 유한한 숫자이고 뷰포트 안일 때만
assert.deepEqual(core.canvasPoint(10, 20, 800, 600), { x: 10, y: 20 }); assert.deepEqual(core.canvasPoint(800, 600, 800, 600), { x: 800, y: 600 });
for (const bad of [[-1, 0], [0, -1], [801, 0], [0, 601], [NaN, 0], ['10', 20], [Infinity, 0], [null, 0], [undefined, undefined]]) assert.equal(core.canvasPoint(bad[0], bad[1], 800, 600), null, JSON.stringify(bad));
assert.equal(core.isComponentId('paragraph'), true); assert.equal(core.isComponentId('evil'), false); assert.equal(core.isComponentId(''), false); assert.equal(core.isComponentId(null), false);
});
test('visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리', async () => {
const ui = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false }); const { ve, document, els } = ui;
const frameEl = document.getElementById('frame'); frameEl.contentWindow.postMessage = () => { throw new Error('연결 뒤에는 창 메시지를 쓰지 않는다'); };
frameEl.getBoundingClientRect = () => ({ left: 300, top: 100, width: 600, height: 400, right: 900, bottom: 500 }); frameEl.clientLeft = 0; frameEl.clientTop = 0; frameEl.clientWidth = 600; frameEl.clientHeight = 400; void els;
await ve.open('index.html'); const sent = connectCanvas(ui, 'ab'.repeat(16)).sent; sent.length = 0; // 명령은 MessagePort 로 간다(연결 규약 테스트 참고)
const li = document.created.find((e) => e.tagName === 'li' && e.className === 've-comp'); const compId = core.COMPONENTS[0].id;
assert.ok(li._ev.pointerdown && li._ev.pointermove && li._ev.pointerup, 'pointerdown/move/up 처리기');
const ev = (x, y, over) => Object.assign({ clientX: x, clientY: y, pointerId: 7, pointerType: 'mouse', button: 0, target: li, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }, over || {});
const types = () => sent.map((m) => m.type); const plain = (o) => JSON.parse(JSON.stringify(o)); // vm 컨텍스트 객체는 프로토타입이 달라 JSON 으로 비교
// 1) 바깥에서 누르고 문턱 아래로 움직임 → 메시지 없음, 포인터 캡처는 걸림
li.fire('pointerdown', ev(100, 100)); assert.equal(li._captured, 7, 'setPointerCapture'); li.fire('pointermove', ev(102, 101)); assert.deepEqual(types(), []);
// 2) 문턱 넘어 iframe 바깥 이동 → 아직 메시지 없음(끌기 시작 표시만)
li.fire('pointermove', ev(130, 130)); assert.deepEqual(types(), []); assert.equal(li.classList.contains('ve-comp-dragging'), true); assert.equal(document.body.classList.contains('ve-palette-dragging'), true);
// 3) iframe 안으로 → paletteDragAt(상대 좌표: 400-300, 200-100)
li.fire('pointermove', ev(400, 200)); assert.deepEqual(plain(sent.at(-1)), core.makeMessage('ab'.repeat(16), 'paletteDragAt', { component: compId, x: 100, y: 100 }), '봉투(wmve·세션·종류·payload) 그대로');
// 4) 밖으로 → paletteDragCancel 한 번, 다시 안으로 → paletteDragAt
li.fire('pointermove', ev(10, 10)); assert.equal(types().at(-1), 'paletteDragCancel'); li.fire('pointermove', ev(5, 5)); assert.equal(types().filter((t) => t === 'paletteDragCancel').length, 1, '바깥에서 계속 움직여도 취소는 한 번');
li.fire('pointermove', ev(450, 250)); assert.deepEqual(plain(sent.at(-1).payload), { component: compId, x: 150, y: 150 });
// 5) 안에서 pointerup → paletteDropAt(상대 좌표), 캡처·표시 해제
li.fire('pointerup', ev(460, 260)); assert.equal(sent.at(-1).type, 'paletteDropAt'); assert.deepEqual(plain(sent.at(-1).payload), { component: compId, x: 160, y: 160 });
assert.equal(li._captured, null, 'releasePointerCapture'); assert.equal(li.classList.contains('ve-comp-dragging'), false); assert.equal(document.body.classList.contains('ve-palette-dragging'), false);
const n5 = sent.length; li.fire('pointermove', ev(470, 270)); li.fire('pointerup', ev(470, 270)); assert.equal(sent.length, n5, '끝난 뒤 이벤트는 무시');
// 6) "추가" 버튼 위에서 누르면 끌기를 시작하지 않는다(버튼 클릭 보존)
sent.length = 0; const btn = { tagName: 'BUTTON' }; li.fire('pointerdown', ev(100, 100, { target: { closest: (s) => (s === 'button' ? btn : null) } })); li.fire('pointermove', ev(400, 200)); li.fire('pointerup', ev(400, 200));
assert.deepEqual(types(), [], '버튼에서 시작한 포인터는 끌기 아님'); assert.equal(li._captured, null);
// 7) 짧은 클릭(문턱 미만에서 up) → 메시지 없음
li.fire('pointerdown', ev(100, 100)); li.fire('pointerup', ev(101, 101)); assert.deepEqual(types(), []); assert.equal(li._captured, null);
// 8) 터치 포인터는 다리를 쓰지 않는다(팔레트 스크롤 보존) · 보조 버튼도 아님
li.fire('pointerdown', ev(100, 100, { pointerType: 'touch' })); li.fire('pointermove', ev(400, 200, { pointerType: 'touch' })); assert.deepEqual(types(), []); assert.equal(li._captured, null);
li.fire('pointerdown', ev(100, 100, { button: 2 })); li.fire('pointermove', ev(400, 200, { button: 2 })); li.fire('pointerup', ev(400, 200, { button: 2 })); assert.deepEqual(types(), []);
// 9) Escape 로 취소 → paletteDragCancel, 이후 pointerup 은 아무것도 보내지 않음
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); assert.equal(types().at(-1), 'paletteDragAt');
document.fire('keydown', { key: 'Escape', ctrlKey: false, metaKey: false, preventDefault() {} }); assert.equal(types().at(-1), 'paletteDragCancel'); assert.equal(li._captured, null);
const n9 = sent.length; li.fire('pointerup', ev(400, 200)); assert.equal(sent.length, n9, 'Escape 뒤 up 은 놓기 아님');
// 10) pointercancel(브라우저가 끌기를 가로챔) → 취소 정리
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); li.fire('pointercancel', ev(400, 200)); assert.equal(types().at(-1), 'paletteDragCancel'); assert.equal(li._captured, null); assert.equal(document.body.classList.contains('ve-palette-dragging'), false);
// 11) 포인터 끌기 중 브라우저 기본 HTML5 dragstart 가 나면 막는다(포인터 경로가 주 경로, pointercancel 로 끊기지 않게). 끌기 중이 아니면 HTML5 자료는 그대로 넣는다(대체 경로)
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(130, 130)); const data = {}; const ds = li.fire('dragstart', { dataTransfer: { setData: (k, v) => { data[k] = v; }, effectAllowed: '' }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } });
assert.equal(ds.defaultPrevented, true, '포인터 끌기 중 네이티브 dragstart 차단'); li.fire('pointerup', ev(10, 10));
const data2 = {}; const ds2 = li.fire('dragstart', { dataTransfer: { setData: (k, v) => { data2[k] = v; }, effectAllowed: '' }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } });
assert.equal(ds2.defaultPrevented, false); assert.equal(data2['text/plain'], core.componentDragText(compId), 'HTML5 대체 경로 유지');
// 12) 직접 편집을 끝낸 뒤에는 포인터 다리가 메시지를 보내지 않는다
ve.close(true); sent.length = 0; li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); li.fire('pointerup', ev(400, 200)); assert.deepEqual(types(), []);
});
test('wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다', () => {
const { doc, messages, fromParent, fromWindow, kids, dropBar } = loadCanvas('<h1>제목</h1><p>본문</p>');
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
const snapshot = () => doc.body.innerHTML; const n0 = kids().length;
// 1) 끌기 위치 → 놓기 표시(h1 위쪽 절반 → h1 앞 선)
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); assert.equal(dropBar().hidden, false, '놓기 표시 보임'); assert.equal(dropBar().style.top, '-1px', 'h1 위 선');
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 70 }); assert.equal(dropBar().style.top, '79px', 'p 아래 선(아래쪽 절반)');
// 2) 취소 → 숨김
fromParent('paletteDragCancel', {}); assert.equal(dropBar().hidden, true);
// 3) 잘못된 id·좌표는 표시를 숨기고 아무것도 하지 않는다
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'evil', x: 10, y: 10 }); assert.equal(dropBar().hidden, true, '모르는 id');
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'paragraph', x: -5, y: 10 }); assert.equal(dropBar().hidden, true, '뷰포트 밖 좌표');
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'paragraph', x: '10', y: 10 }); assert.equal(dropBar().hidden, true, '숫자 아닌 좌표');
// 4) 놓기 → h1 앞에 문단 하나, changed 한 번(표식 없음), 새 요소 선택, 표시 숨김
const m0 = messages.length; fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 });
fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
assert.equal(kids().length, n0 + 1); assert.equal(kids()[0].tagName, 'P'); assert.equal(kids()[0].textContent, '여기에 내용을 입력하세요.'); assert.equal(kids()[1], h1); assert.ok(kids()[0].hasAttribute('data-wm-ve-selected'));
const changed = messages.slice(m0).filter((m) => m.type === 'changed'); assert.equal(changed.length, 1); assert.ok(changed[0].payload.body.startsWith('<p>여기에 내용을 입력하세요.</p><h1>제목</h1>'), changed[0].payload.body); assert.ok(!/data-wm-ve-|wm-ve-/.test(changed[0].payload.body));
assert.equal(dropBar().hidden, true); assert.equal(messages.slice(m0).filter((m) => m.type === 'error').length, 0);
// 5) 빈 곳(요소 없음) 놓기 → 본문 끝
const before5 = kids().length; fromParent('paletteDropAt', { component: 'divider', x: 10, y: 500 }); assert.equal(kids().length, before5 + 1); assert.equal(kids().at(-1).tagName, 'HR', '끝에 구분선');
// 6) 잘못된 입력은 아무것도 넣지 않고 표식·메시지도 없다: 모르는 id, 좌표 누락/범위 밖/문자열, payload 없음, 미리보기 모드
const s6 = snapshot(); const n6 = kids().length; const m6 = messages.length;
fromParent('paletteDropAt', { component: 'evil', x: 10, y: 10 }); fromParent('paletteDropAt', { component: 'paragraph' }); fromParent('paletteDropAt', { component: 'paragraph', x: 9999, y: 10 }); fromParent('paletteDropAt', { component: 'paragraph', x: '10', y: '10' }); fromParent('paletteDropAt', {});
assert.equal(kids().length, n6); assert.equal(snapshot(), s6); assert.equal(dropBar().hidden, true);
fromParent('setMode', { mode: 'preview' }); const m6b = messages.length; fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); assert.equal(dropBar().hidden, true, '미리보기 모드: 표시 없음'); fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
assert.equal(kids().length, n6, '미리보기 모드: 삽입 없음'); assert.equal(messages.slice(m6b).filter((m) => m.type === 'changed' || m.type === 'error').length, 0);
fromParent('setMode', { mode: 'edit' });
assert.equal(messages.slice(m6, m6b).filter((m) => m.type === 'changed' || m.type === 'error').length, 0, '잘못된 입력에 changed·error 없음');
// 7) 규약 밖 메시지(다른 세션의 포트 메시지·창으로 온 메시지)는 무시된다
const n7 = kids().length; fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 }, { data: core.makeMessage('other', 'paletteDropAt', { component: 'paragraph', x: 10, y: 10 }) }); fromWindow('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
assert.equal(kids().length, n7, '세션이 다르거나 창으로 오면 무시');
});
test('wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다', () => {
const { doc, messages, fire, kids, dropBar } = loadCanvas('<h1>제목</h1><p>본문</p>');
assert.ok(messages.some((m) => m.type === 'ready'), '런타임 부트'); assert.ok(dropBar() && dropBar().hidden === true, '놓기 표시는 처음엔 숨김');
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
const dt = (data) => ({ types: Object.keys(data), getData: (k) => (k in data ? data[k] : ''), dropEffect: '' });
const snapshot = () => doc.body.innerHTML; const n0 = kids().length; const m0 = messages.length;
// 1) 전용 MIME 없이 text/plain 만 보이는 dragover(브라우저는 drop 전까지 getData 를 막는다) → 끌기로 인식해 기본 동작을 막고 놓기 표시를 보인다
const over = fire('dragover', { dataTransfer: { types: ['text/plain'], getData: () => '' , dropEffect: '' }, clientX: 10, clientY: 10 });
assert.equal(over.defaultPrevented, true, 'dragover preventDefault(놓기 허용)'); assert.equal(over.dataTransfer.dropEffect, 'copy'); assert.equal(dropBar().hidden, false, '놓기 표시 보임');
// 2) drop: 전용 MIME 이 사라지고 text/plain 이름 공간 값만 남은 경우 → 문단 하나가 h1 앞(위쪽 절반에 놓음)에 들어간다
const drop = fire('drop', { dataTransfer: dt({ 'text/plain': 'wm-ve-component:paragraph' }), clientX: 10, clientY: 10 });
assert.equal(drop.defaultPrevented, true);
assert.equal(kids().length, n0 + 1, '요소 하나 추가'); assert.equal(kids()[0].tagName, 'P'); assert.equal(kids()[0].textContent, '여기에 내용을 입력하세요.'); assert.equal(kids()[1], h1, 'h1 앞에 삽입');
assert.equal(dropBar().hidden, true, '놓기 표시 숨김'); assert.ok(kids()[0].hasAttribute('data-wm-ve-selected'), '새 요소 선택');
const changed = messages.slice(m0).filter((m) => m.type === 'changed'); assert.equal(changed.length, 1, 'changed 한 번');
assert.ok(changed[0].payload.body.startsWith('<p>여기에 내용을 입력하세요.</p><h1>제목</h1>'), changed[0].payload.body); assert.ok(!/data-wm-ve-|wm-ve-/.test(changed[0].payload.body), '부모에 보낸 본문에 표식 없음');
// 3) 임의 text/plain(주소·HTML 조각)·모르는 id·정확히 일치하지 않는 값 → 아무것도 넣지 않고 표식도 남기지 않는다
const before = snapshot(); const n1 = kids().length; const m1 = messages.length;
for (const bad of ['https://example.com/x', '<img src=x onerror=alert(1)>', 'wm-ve-component:evil', 'wm-ve-component:paragraph ', 'paragraph']) {
fire('dragover', { dataTransfer: { types: ['text/plain'], getData: () => '', dropEffect: '' }, clientX: 10, clientY: 60 });
const d = fire('drop', { dataTransfer: dt({ 'text/plain': bad }), clientX: 10, clientY: 60 });
assert.equal(kids().length, n1, '추가 없음: ' + JSON.stringify(bad)); assert.equal(snapshot(), before, '본문 불변: ' + JSON.stringify(bad));
assert.equal(dropBar().hidden, true, '놓기 표시 숨김: ' + JSON.stringify(bad)); assert.equal(d.defaultPrevented, true, '브라우저 기본 drop(글 삽입·이동) 차단: ' + JSON.stringify(bad));
}
assert.equal(messages.slice(m1).filter((m) => m.type === 'changed' || m.type === 'error').length, 0, 'changed·error 메시지 없음');
// 4) 전용 MIME 이 남아 있는 경우(같은 출처 등)는 그대로 우선 — 제목(h2) 삽입
fire('drop', { dataTransfer: dt({ 'text/wm-ve-component': 'heading', 'text/plain': 'wm-ve-component:paragraph' }), clientX: 10, clientY: 60 });
assert.equal(kids().length, n1 + 1); assert.ok(kids().some((k) => k.tagName === 'H2' && k.textContent === '새 제목'), '전용 MIME 의 heading 이 우선');
// 5) 구성 요소 끌기가 아닌 것(파일·HTML 만) 은 dragover 도 drop 도 건드리지 않는다
const n2 = kids().length; const s2 = snapshot();
const o5 = fire('dragover', { dataTransfer: { types: ['Files'], getData: () => '', dropEffect: '' }, clientX: 10, clientY: 10 }); assert.equal(o5.defaultPrevented, false);
const d5 = fire('drop', { dataTransfer: dt({ 'text/html': '<b>x</b>', 'text/uri-list': 'https://e/' }), clientX: 10, clientY: 10 }); assert.equal(d5.defaultPrevented, false); assert.equal(kids().length, n2); assert.equal(snapshot(), s2);
});
test('core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다', () => {
const ctx = { window: {} }; ctx.window.window = ctx.window;
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
assert.ok(ctx.window.WMVisualCore && typeof ctx.window.WMVisualCore.createHistory === 'function');
for (const f of ['public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) { const src = fs.readFileSync(path.join(ROOT, f), 'utf8'); new vm.Script(src, { filename: f }); assert.ok(!/\beval\(|new Function\(/.test(src), f + ' 에 eval 없음'); }
});
test('core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도', () => {
const h = core.createHistory(3); h.reset('a');
assert.equal(h.canUndo(), false); assert.equal(h.canRedo(), false);
h.push('b'); h.push('c'); assert.equal(h.current(), 'c'); assert.equal(h.canUndo(), true);
assert.equal(h.undo(), 'b'); assert.equal(h.undo(), 'a'); assert.equal(h.undo(), null); assert.equal(h.canUndo(), false);
assert.equal(h.redo(), 'b'); assert.equal(h.canRedo(), true); h.push('d'); assert.equal(h.canRedo(), false); assert.equal(h.redo(), null);
h.push('e'); h.push('f'); assert.equal(h.size(), 3, '한도를 넘으면 오래된 것부터 버린다'); assert.equal(h.undo(), 'e'); assert.equal(h.undo(), 'd'); assert.equal(h.undo(), null);
h.push('d'); assert.equal(h.current(), 'd', '같은 내용은 중복 기록하지 않는다'); assert.equal(h.canUndo(), false);
});
test('core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시', () => {
const s = core.createSaveState();
assert.equal(s.state(), 'clean'); assert.equal(s.isDirty(), false);
assert.equal(s.beginSave(), false, '변경이 없으면 저장을 시작하지 않는다');
s.markDirty(); assert.equal(s.state(), 'dirty'); assert.match(s.label(), /저장되지 않/);
assert.equal(s.beginSave(), true); assert.equal(s.state(), 'saving'); assert.match(s.label(), /저장 중/);
s.markDirty(); assert.equal(s.state(), 'saving', '저장 중의 추가 변경은 저장 뒤 dirty 로 남는다'); s.saveOk(); assert.equal(s.state(), 'dirty');
s.beginSave(); s.saveOk(); assert.equal(s.state(), 'saved'); assert.match(s.label(), /저장됨/); assert.equal(s.isDirty(), false);
s.markDirty(); s.beginSave(); s.saveFail('네트워크'); assert.equal(s.state(), 'error'); assert.match(s.label(), /저장 실패.*네트워크/); assert.equal(s.isDirty(), true);
s.reset(); assert.equal(s.state(), 'clean');
});
test('core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다', () => {
const frameWin = {}; const session = 's1';
const okEv = { source: frameWin, origin: 'null', data: core.makeMessage(session, 'changed', { body: '<p>x</p>' }) };
const chk = (ev, o = {}) => core.validateMessage(ev, { expectedSource: frameWin, expectedOrigin: 'null', session, direction: 'fromCanvas', ...o });
assert.equal(chk(okEv), true);
assert.equal(chk({ ...okEv, source: {} }), false, '다른 창');
assert.equal(chk({ ...okEv, origin: 'http://evil' }), false, '다른 출처');
assert.equal(chk({ ...okEv, data: core.makeMessage('other', 'changed', {}) }), false, '다른 세션');
assert.equal(chk({ ...okEv, data: core.makeMessage(session, 'init', {}) }), false, '캔버스가 보낼 수 없는 종류');
assert.equal(chk({ ...okEv, data: 'changed' }), false, '문자열');
assert.equal(chk({ ...okEv, data: { ...okEv.data, payload: 'x' } }), false, 'payload 는 객체');
assert.equal(chk(okEv, { session: '' }), false, '세션이 없으면 아무것도 통과하지 않는다');
const toEv = { source: {}, origin: 'http://h', data: core.makeMessage(session, 'insert', { component: 'heading' }) };
assert.equal(core.validateMessage(toEv, { expectedSource: toEv.source, expectedOrigin: 'http://h', session, direction: 'toCanvas' }), true);
});
test('core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다', () => {
const ids = core.COMPONENTS.map((c) => c.id);
for (const need of ['section', 'heading', 'paragraph', 'button', 'image', 'divider', 'spacer']) assert.ok(ids.includes(need), need);
for (const c of core.COMPONENTS) { assert.ok(c.label && /[가-힣]/.test(c.label), '한국어 이름'); const h = core.componentHtml(c.id); assert.ok(h && core.isSafeComponentHtml(h), c.id); assert.ok(!/<script|\son\w+=|javascript:/i.test(h)); }
assert.equal(core.componentHtml('nope'), null); assert.equal(core.isSafeComponentHtml('<p onclick="x()">a</p>'), false); assert.equal(core.isSafeComponentHtml('<a href="javascript:1">a</a>'), false); assert.equal(core.isSafeComponentHtml('<iframe src="x"></iframe>'), false);
});
test('core.moveIndex·dropIndexFor: 재배치 계산', () => {
assert.deepEqual(core.moveIndex(['a', 'b', 'c'], 0, 2), ['b', 'c', 'a']); assert.deepEqual(core.moveIndex(['a', 'b', 'c'], 2, 0), ['c', 'a', 'b']); assert.deepEqual(core.moveIndex(['a', 'b'], 5, 0), ['a', 'b']);
const rects = [{ top: 0, bottom: 100 }, { top: 100, bottom: 200 }, { top: 200, bottom: 300 }];
assert.equal(core.dropIndexFor(rects, 20), 0); assert.equal(core.dropIndexFor(rects, 80), 1); assert.equal(core.dropIndexFor(rects, 160), 2); assert.equal(core.dropIndexFor(rects, 290), 3); assert.equal(core.dropIndexFor([], 10), 0);
});
test('core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙', () => {
assert.equal(core.safeUrlInput('javascript:alert(1)', 'link').ok, false); assert.equal(core.safeUrlInput(' JavaScript:x', 'link').ok, false); assert.equal(core.safeUrlInput('data:text/html,x', 'link').ok, false);
assert.equal(core.safeUrlInput('https://a.b/c', 'link').ok, true); assert.equal(core.safeUrlInput('about.html', 'link').value, 'about.html'); assert.equal(core.safeUrlInput('#top', 'link').ok, true); assert.equal(core.safeUrlInput('mailto:a@b.c', 'link').ok, true); assert.equal(core.safeUrlInput('tel:010', 'link').ok, true);
assert.equal(core.safeUrlInput('data:image/png;base64,AAAA', 'image').ok, true); assert.equal(core.safeUrlInput('data:image/png;base64,AAAA', 'link').ok, false); assert.equal(core.safeUrlInput('data:text/html,<b>', 'image').ok, false);
assert.match(core.safeUrlInput('javascript:1', 'link').error, /[가-힣]/);
assert.ok(core.propsFor('a').includes('href') && core.propsFor('a').includes('text')); assert.ok(core.propsFor('img').includes('src') && core.propsFor('img').includes('alt') && !core.propsFor('img').includes('text'));
for (const k of ['text', 'align', 'color', 'background', 'padding', 'margin']) assert.ok(core.propsFor('p').includes(k), k);
assert.ok(!core.propsFor('hr').includes('text')); assert.ok(core.propsFor('section').includes('background'));
assert.equal(core.labelFor('h1'), '제목 1'); assert.equal(core.labelFor('p'), '문단'); assert.ok(/[가-힣]/.test(core.labelFor('zzz')));
});