Files
AriWeb-Suite/test/webmaker-visual-editor.test.mjs
T

117 lines
14 KiB
JavaScript
Raw Normal View History

/**
* test/webmaker-visual-editor.test.mjs — Ariweb Suite 0.2.0: 도입 모듈 Webmaker 1.8.0 의 직접 편집(시각 편집기) 회귀 검증.
* Suite 쪽에서 확인할 것: ① 도입된 순수 모듈(modules/webmaker/lib/visual-editor.js · public/visual-editor-core.js)이 Suite 체크아웃에서 그대로 동작하고,
* ② Suite 전용 변경(동시 편집 rev 충돌 409, [Ariweb Suite 변경])이 직접 편집 저장 경로에도 실제로 적용되며(서버를 띄워 확인), ③ 화면에 필수 접근성 조작이 있다.
* 브라우저 없이 Node 로만 검사한다. Webmaker 자체 전체 테스트(modules/webmaker: npm test)는 별도로 실행한다.
*/
import { test, eq, ok, done, sleep } from '../lib/test.mjs';
import { createRequire } from 'node:module';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync, writeFileSync, mkdirSync, mkdtempSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';
// 비밀값(설정 토큰·비밀번호)은 실행마다 만들고 변수로만 넘긴다 — 소스에 고정 문자열로 두지 않고 출력하지도 않는다
const SETUP_TOKEN = randomBytes(24).toString('base64url'); const PW = { alice: randomBytes(12).toString('base64url'), root: randomBytes(12).toString('base64url') };
import { spawn } from 'node:child_process';
const require = createRequire(import.meta.url);
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WM = join(ROOT, 'modules', 'webmaker');
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.9.0(HAL 0.4.0 대응, 직접 편집 유지) · Suite 0.4.1(통합 설정 관제 + 등록 TLS·부트스트랩 Agent) 이다', () => {
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.9.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1');
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
else { eq(imported.commit, pin, '깨끗한 기록은 스크립트의 고정 커밋과 같다'); eq(imported.upstream && imported.upstream.headCommit, pin, 'headCommit = pin'); }
eq(imported.upstream && imported.upstream.version, '1.9.0');
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
});
await test('lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다', () => {
const ve = require(join(WM, 'lib', 'visual-editor.js'));
const page = '<!doctype html><html><head><title>t</title><script>boot()</script></head><body><h1 onclick="a()">안녕</h1><p>본문</p></body></html>';
const canvas = ve.prepareCanvas(page, { baseHref: 'http://h/x/', session: 's', nonce: 'n', parentOrigin: 'http://h', runtime: '1' });
ok(/<script type="text\/x-wm-ve-disabled">boot\(\)<\/script>/.test(canvas)); ok(canvas.includes('data-wm-ve-on-click="a()"') && !/\sonclick=/.test(canvas));
const body = ve.splitDocument(canvas).body.replace('본문', '본문 수정');
const r = ve.validateVisualSave({ original: page, body, maxBytes: 1e6 });
ok(r.html.includes('<h1 onclick="a()">안녕</h1><p>본문 수정</p>') && r.html.startsWith('<!doctype html><html><head><title>t</title><script>boot()</script></head><body>'));
let err = null; try { ve.validateVisualSave({ original: page, body: body + '<script>x()</script>', maxBytes: 1e6 }); } catch (e) { err = e; } eq(err && err.status, 400); ok(/스크립트/.test(err.message));
});
await test('visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다', () => {
const core = require(join(WM, 'public', 'visual-editor-core.js'));
const h = core.createHistory(10); h.reset('a'); h.push('b'); eq(h.undo(), 'a'); eq(h.redo(), 'b');
const s = core.createSaveState(); s.markDirty(); ok(s.beginSave()); s.saveOk(); eq(s.state(), 'saved');
const win = {}; ok(core.validateMessage({ source: win, origin: 'null', data: core.makeMessage('s', 'changed', {}) }, { expectedSource: win, expectedOrigin: 'null', session: 's', direction: 'fromCanvas' }));
ok(!core.validateMessage({ source: win, origin: 'null', data: core.makeMessage('t', 'changed', {}) }, { expectedSource: win, expectedOrigin: 'null', session: 's', direction: 'fromCanvas' }));
for (const id of ['section', 'heading', 'paragraph', 'button', 'image', 'divider', 'spacer']) ok(core.isSafeComponentHtml(core.componentHtml(id)), id);
});
await test('화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다', () => {
const html = readFileSync(join(WM, 'public', 'index.html'), 'utf8');
ok(/<button id="btnVisual"[^>]*aria-pressed="false"/.test(html), '직접 편집 전환 버튼');
for (const id of ['veSave', 'veUndo', 'veRedo', 'veDiscard', 'veClose', 'veState', 'vePalette', 'veInspector']) ok(html.includes(`id="${id}"`), id);
ok(/id="veState"[^>]*aria-live="polite"/.test(html), '저장 상태는 aria-live');
ok(html.includes('/visual-editor-core.js') && html.includes('/visual-editor-ui.js'));
ok(html.includes('id="ariwebBanner"'), 'Suite 전용 배너 보존');
const app = readFileSync(join(WM, 'public', 'app.js'), 'utf8'); ok(app.includes('[Ariweb Suite 변경] 포털 SSO 진입') && app.includes('VisualEditor'), 'app.js 에 Suite 변경과 편집기 연결이 함께');
});
await test('server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버)', async () => {
const PORT = 4500 + Math.floor(Math.random() * 90); const tmp = mkdtempSync(join(tmpdir(), 'aw-wm-ve-')); const B = `http://127.0.0.1:${PORT}`; const jar = {};
writeFileSync(join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 });
// 사이트 생성이 띄우는 AI 작업에 개발 PC 의 진짜 claude 가 쓰이지 않게(비용·시간) 바로 실패하는 가짜 실행 파일을 쓴다
const fake = join(tmp, 'fake-claude.sh'); writeFileSync(fake, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
const child = spawn(process.execPath, [join(WM, 'server.js')], { env: { ...process.env, CLAUDE_BIN: fake, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: join(tmp, 'sites'), SITES_ROOT: join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
try {
for (let i = 0; i < 100 && !/listening on/.test(out); i++) await sleep(100);
ok(/listening on/.test(out), out.slice(-300));
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', referer: `${B}/`, ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b }; };
eq((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) })).status, 200);
eq((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200);
ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300);
eq((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
eq((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200);
const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE', purpose: 'p', content: 'c' }) }); ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id;
mkdirSync(join(tmp, 'www', id), { recursive: true }); writeFileSync(join(tmp, 'www', id, 'index.html'), '<!doctype html><html><head><title>t</title></head><body><h1>제목</h1></body></html>');
for (let i = 0; i < 100 && (await api(`/api/sites/${id}`)).b.running; i++) await sleep(100); // 생성 때 시작된(실패하는) AI 작업이 끝날 때까지
const g = await api(`/api/sites/${id}/visual/index.html`); eq(g.status, 200); ok(typeof g.b.rev === 'number', '캔버스 응답에 rev');
const stale = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>충돌</h1>', baseHash: g.b.hash, rev: g.b.rev + 5 }) });
eq(stale.status, 409, JSON.stringify(stale.b)); ok(/다른 사용자|먼저 저장/.test(stale.b.error)); ok(stale.b.conflict && stale.b.conflict.currentRev === g.b.rev, 'rev 충돌 요약');
const okPut = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) });
eq(okPut.status, 200, JSON.stringify(okPut.b)); ok(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8').includes('<h1>저장</h1>'));
const s = await api(`/api/sites/${id}`); eq(s.b.versions[s.b.versions.length - 1].kind, 'visual-edit'); ok(s.b.rev > g.b.rev, '저장 뒤 rev 증가');
// ---- 검토 수정(독립 검토 차단 항목 7): 직접 편집 저장은 유한한 숫자 rev 가 필수 — 없음·문자열·null·NaN 문자열은 400(fail-closed), 파일은 그대로 ----
const g2 = await api(`/api/sites/${id}/visual/index.html`); eq(g2.status, 200); const before = readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8');
for (const [label, body] of [['rev 없음', { body: '<h1>rev 없음</h1>', baseHash: g2.b.hash }], ['문자열 rev', { body: '<h1>문자열</h1>', baseHash: g2.b.hash, rev: String(g2.b.rev) }], ['null rev', { body: '<h1>null</h1>', baseHash: g2.b.hash, rev: null }], ['NaN 문자열', { body: '<h1>NaN</h1>', baseHash: g2.b.hash, rev: 'NaN' }], ['빈 문자열', { body: '<h1>빈</h1>', baseHash: g2.b.hash, rev: '' }]]) {
const r = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify(body) });
eq(r.status, 400, `${label}: ${JSON.stringify(r.b)}`); ok(/rev/.test(r.b.error), label + ' 안내에 rev'); eq(r.b.revRequired, true, label);
}
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, 'rev 없는 저장은 파일을 바꾸지 않는다');
// 다른 파일의 저장으로 rev 가 오르면(cross-file) 옛 rev 로 보낸 저장은 409 — 파일 hash 가 맞아도 조용히 덮어쓰지 않는다
writeFileSync(join(tmp, 'www', id, 'about.html'), '<!doctype html><html><head><title>a</title></head><body><p>소개</p></body></html>');
const ga = await api(`/api/sites/${id}/visual/about.html`); eq(ga.status, 200); eq(ga.b.rev, g2.b.rev, '같은 사이트 rev');
const saveA = await api(`/api/sites/${id}/visual/about.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>소개 수정</p>', baseHash: ga.b.hash, rev: ga.b.rev }) }); eq(saveA.status, 200, JSON.stringify(saveA.b)); ok(saveA.b.rev > ga.b.rev);
const cross = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>교차</h1>', baseHash: g2.b.hash, rev: g2.b.rev }) });
// currentRev 는 saveA 응답의 rev 이상(원격 저장소가 없으면 gitRecord 가 뒤늦게 retryPush → store.save 로 rev 를 한 번 더 올릴 수 있다)
eq(cross.status, 409, 'about.html 저장으로 오른 rev: ' + JSON.stringify(cross.b)); ok(cross.b.conflict && cross.b.conflict.currentRev >= saveA.b.rev && cross.b.conflict.yourRev === g2.b.rev, '충돌 요약(현재 rev·내 rev): ' + JSON.stringify(cross.b.conflict));
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, '교차 충돌 저장은 파일을 바꾸지 않는다');
// ---- 검토 수정(차단 항목 6, Suite 쪽): 사이트 기록 저장이 실패하면 되돌리고 500 — rev 도 저장 전 값 그대로(되돌리기 저장은 rev 를 올리지 않는다), 같은 rev·baseHash 로 재시도 성공 ----
const g3 = await api(`/api/sites/${id}/visual/index.html`); const rev3 = (await api(`/api/sites/${id}`)).b.rev; eq(g3.b.rev, rev3);
chmodSync(join(tmp, 'sites'), 0o555);
let fail; try { fail = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>기록 실패</h1>', baseHash: g3.b.hash, rev: g3.b.rev }) }); } finally { chmodSync(join(tmp, 'sites'), 0o755); }
eq(fail.status, 500, JSON.stringify(fail.b)); ok(/되돌/.test(fail.b.error));
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, '페이지 되돌림'); eq((await api(`/api/sites/${id}`)).b.rev, rev3, '실패한 저장은 rev 를 바꾸지 않는다');
const retry = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>재시도</h1>', baseHash: g3.b.hash, rev: g3.b.rev }) }); eq(retry.status, 200, '같은 rev·baseHash 로 재시도: ' + JSON.stringify(retry.b)); ok(retry.b.rev > rev3);
} finally { try { child.kill('SIGTERM'); } catch (_) {} }
});
done();