2026-10-28 15:07:35 +00:00
/**
* 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' );
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
await test ( '도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.9(HAL AI 셸·모델 선택·슬래시 명령, 도입 모듈 변경 없음) 이다' , () => {
2026-10-28 15:07:35 +00:00
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.8.0' );
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.3.9' );
2026-10-28 15:07:35 +00:00
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.8.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 ();