86 lines
10 KiB
JavaScript
86 lines
10 KiB
JavaScript
'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);
|
||
|
|
});
|