Files
AriWeb-Suite/modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md
T

487 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 직접 편집(시각 편집기) 1.8.0 — TDD RED→GREEN 기록
- 작업 트리: `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`, 브랜치 `feature/visual-editor-v1.8.0`, 기준 `origin/main 3b10859884ef2fd0f2af11934ab6c16370fd64b8`
- 날짜: 2026-10-28 · Node v24.22.1 · 커밋·push·배포 없음(Neo 검토 전 구현 인도)
- 아래 출력은 실제 실행 결과에서 그대로 옮겼다(요약을 위해 스택 줄만 줄임). 실행 때 `CLAUDE_BIN` 은 바로 실패하는 가짜 실행 파일로 두어 개발 PC 의 진짜 `claude` 가 사이트 생성 작업으로 실행되지 않게 했다(첫 RED 실행에서는 그 설정이 없어 진짜 claude 프로세스가 하나 시작되었고 즉시 종료시켰다 — 이 때문에 두 테스트 파일 모두 가짜 실행 파일을 쓰도록 고쳤다).
## 조각 1·2 — 서버 순수 모듈(lib/visual-editor.js)·공용 핵심(public/visual-editor-core.js)
RED — `node --test test/visual-editor.test.js` (모듈이 없음)
```
Error: Cannot find module '/home/master/work/WebHoting_AI_Agent-visual-editor-v180/lib/visual-editor'
Require stack:
- /home/master/work/WebHoting_AI_Agent-visual-editor-v180/test/visual-editor.test.js
ℹ pass 0
ℹ fail 1
```
중간(모듈 구현 뒤 첫 실행) — 12/15 통과, 3 실패: ① 테스트의 정규식이 보존 속성 `data-wm-ve-href="javascript:…"` 까지 잡아 `/\shref="javascript:/` 로 고침, ② `restoreFromCanvas` 가 덧씌운 UI 요소 안의 **글자**("삭제")를 남겨 범위 제거(`stripUiElements`)로 구현 수정, ③ 런타임 파일(`public/wm-ve-canvas.js`·`public/visual-editor-ui.js`) 미존재(조각 4 에서 해결).
GREEN — `node --test test/visual-editor.test.js`
```
✔ splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다
✔ prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다
✔ restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다
✔ validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다
✔ validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다
✔ validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절
✔ validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다
✔ contentHash·isHtmlPath
✔ core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다
✔ core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도
✔ core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시
✔ core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다
✔ core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다
✔ core.moveIndex·dropIndexFor: 재배치 계산
✔ core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙
ℹ tests 15
ℹ pass 15
ℹ fail 0
```
## 조각 3·4 — 서버 경로(server.js)·화면(index.html·style.css·app.js·visual-editor-ui.js·wm-ve-canvas.js)
RED — `node --test test/visual-editor-server.test.js` (경로가 없어 404, 화면에 버튼 없음)
```
✖ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절
✖ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절
✖ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다
✖ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존
✖ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400
✖ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)
✖ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다
ℹ pass 0
ℹ fail 7
actual: 404,
expected: 200,
```
중간 1 — 4/7 통과: 저장·오래된 저장 테스트가 "AI 작업 실행 중" 409 를 받음 → 사이트 생성이 띄운 AI 작업이 **진짜 claude** 로 실행되고 있었다(개발 PC PATH). 테스트를 가짜 실행 파일(`CLAUDE_BIN`, 즉시 exit 1)로 바꾸고 작업 종료를 기다리게 수정. 화면 테스트의 반응형 정규식(`[^}]*` → `[^@]*`) 수정.
중간 2 — 6/7: 오래된 저장 테스트가 `baseHash:'deadbeef'`(형식 오류 400)를 보냄 → 64자리 16진으로 수정. 그 뒤 JSON 파싱 실패가 500 으로 나와 **서버 수정**(직접 편집 PUT 은 본문 읽기 실패를 400 한국어 안내로).
GREEN — `node --test test/visual-editor-server.test.js`
```
✔ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절 (1266ms)
✔ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절 (24ms)
✔ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다 (11ms)
✔ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존 (138ms)
✔ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400 (147ms)
✔ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버) (510ms)
✔ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다 (22ms)
ℹ pass 7
ℹ fail 0
ℹ duration_ms 2255.904911
```
## 전체 묶음 — `CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 74
ℹ pass 74
ℹ fail 0
ℹ duration_ms 90188.973409
```
(원본 1.7.3 의 52개 + 새 22개. 기존 테스트 변경 없음.)
## 요구 ↔ 테스트 대응
| 요구 | 테스트 |
|---|---|
| 권한(소유자만)·다른 사용자 404·로그인 401 | server: 권한·출처 |
| CSRF/출처(Referer 없는 fetch·생성 사이트 페이지 출처 거절) | server: 권한·출처 |
| 허용 페이지(HTML 만·경로 탈출·링크·없는 파일) | server: 허용 페이지 · unit: isHtmlPath |
| 낙관적 동시성/오래된 저장 409 + currentHash | server: 오래된 저장 |
| 안전한 정화·규약(스크립트·핸들러·위험 주소·금지 요소·스타일 표현식·구조 태그·표식 제거·머리 보존) | unit: prepareCanvas·restoreFromCanvas·validateVisualSave ×4 · server: 캔버스 계약·저장 |
| 스크립트 비실행 계약(disabled type·CSP nonce·보존 속성·세션·부모 출처) | unit: prepareCanvas · server: 캔버스 계약 |
| 저장이 버전 이력(visual-edit)을 만들고 캐시·AI 명령에 세지 않음 | server: 저장 |
| 실행 취소/다시 실행 핵심 | unit: core.createHistory |
| 끌어 옮기기/구성 요소 삽입 핵심(재배치 계산·구성 요소 안전) | unit: core.moveIndex·dropIndexFor·COMPONENTS |
| 저장 상태/이동 보호 | unit: core.createSaveState · server: 화면(상태 aria-live·버튼) · app.js 연결(정적) |
| 필수 접근성 조작 | server: 화면 |
| AI 작업 중 거절 | server: AI 작업 실행 중 |
| 잘못된 payload·크기 | server: 오래된 저장(400/413) · unit: validateVisualSave |
## 검토 수정(Neo 독립 검토, 2026-10-28) — 동시 저장 경쟁(출시 차단) · 원자적 저장(안전 강화)
**지적 ①(차단)**: 직접 편집 PUT 이 `await readBody(req)` **앞**에서 파일을 읽어 hash 를 잡아 두었다. 두 요청이 본문을 읽는 동안 나란히 들어오면 둘 다 같은 hash 를 통과해 둘째가 첫째를 조용히 덮어쓴다. **지적 ②(강화)**: `fs.writeFileSync` 로 운영 HTML 을 직접 덮어써 쓰기 실패·중단 때 마지막 정상 페이지가 잘릴 수 있다.
RED — 새 테스트 3건 추가 뒤 실행
- `node --test test/visual-editor.test.js` (helper 없음)
```
✖ writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다
TypeError [Error]: ve.writeTextAtomic is not a function
ℹ pass 15
ℹ fail 1
```
- `node --test test/visual-editor-server.test.js` — 경쟁 테스트는 chunked PUT 두 개의 머리+본문 앞 12바이트만 보내 둔 채 400ms 기다려(두 처리기가 본문 읽기에서 멈춤) A 를 끝내고 200 을 받은 **뒤** B 를 끝낸다. 원자성 테스트는 사이트 폴더를 0555 로 바꿔 임시 파일을 만들 수 없게 한다(기존 파일 직접 덮어쓰기는 이 조건에서도 "성공" 한다).
```
✖ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다
AssertionError [ERR_ASSERTION]: 둘째는 오래된 저장: {"path":"index.html", … "change":{"no":2,"kind":"visual-edit", …}}
actual: 200,
expected: 409,
✖ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다
actual: 200,
expected: 500,
ℹ pass 7
ℹ fail 2
```
(둘째 요청이 200 을 받고 파일에 "둘째-B" 가 남아 첫 저장이 사라졐음을 확인 — 재현 성공.)
수정
- `lib/visual-editor.js` `writeTextAtomic(absPath, content, {fs, mode})`: 같은 폴더의 `.<이름>.<pid>.<무작위>.tmp` 에 쓰고(0644, umask 와 무관하게 chmod) rename. 실패하면 임시 파일을 지우고(정리 실패는 무시) 오류를 그대로 던진다. 파일 시스템 함수를 주입할 수 있어 rename 실패·쓰기 실패 경로를 결정적으로 검증한다.
- `server.js` 직접 편집 PUT: 본문을 다 읽은 **뒤** `store.load` 로 사이트 기록을, `fs.readFileSync` 로 파일을 다시 읽어 hash 를 비교하고, 검증·원자적 쓰기까지 await 없이 이어 간다. 쓰기 실패는 500 + "페이지를 저장하지 못했습니다 (…). … 기존 페이지는 그대로 있습니다". GET 은 바뀌지 않았다. 소스 편집기(`PUT /files`)의 기존 쓰기는 손대지 않았다.
- 중간 실행: helper 가 0644 를 돌려주지 않아(테스트 프로세스 umask 077 → 0600) chmod 를 추가.
GREEN
```
node --test test/visual-editor.test.js
ℹ tests 16
ℹ pass 16
ℹ fail 0
node --test test/visual-editor-server.test.js
✔ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다 (655ms)
✔ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다 (243ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
```
전체 묶음(수정 뒤): 아래 "전체 묶음 — 검토 수정 뒤" 절.
## 전체 묶음 — 검토 수정 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 77
ℹ pass 77
ℹ fail 0
ℹ duration_ms 90193.018088
```
(원본 52 + 직접 편집 25. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 84 · pass 84 · fail 0 — 0.1.1 의 ariweb.test.js 7건 포함.)
## 실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트, 2026-10-28) — 직접 편집 중 배치(출시 차단)
**발견(실제 브라우저, 독립 Webmaker 코드)**: 1280×633 에서 직접 편집을 켜도 `.app` 이 레일 232px + 작업 칸 `.left` 460px + 캔버스 세 칸 그대로였고 `.ve-palette`·`.ve-inspector` 가 각 240px 이라 **iframe 실제 폭이 114.625px**, 한국어 제목이 한 글자씩 줄바꿈되었다. 390×844 에서는 `.left` 가 그대로 보여 캔버스가 y≈2228 로 밀려 옛 작업 폼을 다 지나야 편집기가 나왔다.
**원인**: `visual-editor-ui.js` 는 켤 때 `body.ve-on` 을 넣고 끌 때 빼지만, `style.css` 에 그 class 로 작업 칸을 숨기거나 `.app` 격자를 다시 짜는 규칙이 없었다(`.ve-on` 은 전환 버튼 색에만 쓰였다).
RED — `node --test test/visual-editor.test.js` (새 계약 테스트 3건 추가 뒤; CSS 파서가 선택자·속성값을 읽는다 — 주석 문자열은 보지 않는다)
```
✖ style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px
AssertionError [ERR_ASSERTION]: 직접 편집 중 옛 작업 칸 숨김
✖ style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다
AssertionError [ERR_ASSERTION]: 좁은 화면 body.ve-on .app 규칙
✔ visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다
ℹ tests 19
ℹ pass 17
ℹ fail 2
```
세 번째 테스트(가짜 DOM 위에서 `visual-editor-ui.js` 를 실행해 `open()`/`close()` 가 `body.ve-on`·패널·`aria-pressed`·srcdoc 을 켜고 되돌리는지)는 RED 단계에서 이미 통과 — JS 전환은 맞고 **CSS 규칙만 빠졌다**는 진단이 테스트로 확인되었다.
수정(`public/style.css`, 두 저장소 동일)
```
body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
body.ve-on .left { display: none; }
@media (max-width: 1180px) { body.ve-on .ve-side { flex-basis: 190px; } }
@media (max-width: 900px) { … body.ve-on .app { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } body.ve-on .right { min-height: 0; } }
```
계산: 1280px → 캔버스 1280 − 232 − 240×2 = **568px**(양쪽 패널 펼침). 1100px(레일 204px) → 1100 − 204 − 190×2 = 516px. 390px → 레일(가로 띠) 다음 행이 바로 캔버스(`frame-wrap` order 1, min-height 40vh), 팔레트·속성 창은 아래에 쌓이고 기본 접힘. 끝내면 `body.ve-on` 이 빠져 원래 `var(--rail-w) var(--left-w, 460px) 1fr` 세 칸이 그대로 돌아온다(작업 칸 DOM 은 건드리지 않는다).
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
```
ℹ tests 19
ℹ pass 19
ℹ fail 0
```
전체 묶음(수정 뒤): 아래 "전체 묶음 — 배치 수정 뒤".
## 전체 묶음 — 배치 수정 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 80
ℹ pass 80
ℹ fail 0
```
(원본 52 + 직접 편집 28. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 87 · pass 87 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
## 실제 브라우저 시각 검증 수정 2(Neo 의 배치 수정 뒤 게이트, 2026-10-28) — 선택 전 도구 줄 노출(출시 차단)
**발견(실제 브라우저)**: 직접 편집을 켜고 아무 요소도 고르지 않았는데 어두운 선택 도구 줄 `.wm-ve-tools` 가 페이지 위에 이미 그려져 있었다. 속성 창은 "선택한 요소 없음", DOM 은 `tools.hidden = true` 였다.
**원인**: 캔버스 런타임(`wm-ve-canvas.js`)이 끼워 넣는 작성자 규칙 `.wm-ve-tools{…display:flex…}` 가 사용자 에이전트 규칙 `[hidden]{display:none}` 을 덮어쓴다(작성자 origin 은 명시도와 무관하게 UA 를 이긴다). 그래서 `hidden` 속성이 있어도 flex 로 보였다. `renderOverlay()` 가 `hidden=false` 로 바꾸기 전까지 보이지 않아야 하고, 선택 해제·미리보기·`applyHtml` 뒤 다시 숨겨져야 한다.
RED — `node --test test/visual-editor.test.js` (새 계약 테스트 1건 추가 뒤). 테스트는 런타임 소스의 CSS 블록(`st.textContent`)을 꺼내 파싱하고, `hidden` 인 `.wm-ve-tools`·`.wm-ve-drop` 요소의 최종 display 를 cascade 모형(!important > `[hidden]` 명시도 > 나중 선언, 작성자 규칙이 없으면 UA 의 none)으로 계산한다 — 주석 문자열은 보지 않는다.
```
✖ wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다
AssertionError [ERR_ASSERTION]: hidden 인 .wm-ve-tools 의 최종 display
actual: 'flex',
expected: 'none',
ℹ tests 20
ℹ pass 19
ℹ fail 1
```
수정(`public/wm-ve-canvas.js` 런타임 CSS, 두 저장소 byte-identical) — 한 줄 추가, 다른 동작 변경 없음
```
.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}
```
`hidden` 을 켜고 끄는 JS(`renderOverlay`·끌기 시작·`showDrop`·dragleave)는 그대로이며, 보일 때의 `display:flex` 도 그대로다(테스트가 함께 확인).
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
```
ℹ tests 20
ℹ pass 20
ℹ fail 0
```
## 전체 묶음 — 도구 줄 수정 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 81
ℹ pass 81
ℹ fail 0
```
(원본 52 + 직접 편집 29. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 88 · pass 88 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
## 실제 브라우저 게이트 3(Playwright 자동 게이트, 2026-10-28) — 팔레트 → 캔버스 끌어다 놓기 실패(출시 차단)
**발견(실제 Chrome, Playwright frameLocator 로 캔버스 iframe 을 직접 조작)**: 선택·contenteditable 글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동·팔레트 표시까지 19/19 확인 통과. 그러나 부모 팔레트의 "문단" 을 캔버스에 실제 HTML5 끌어다 놓기 하면 아무것도 들어가지 않았다(`시간 안에 되지 않음: 끌어다 놓기 뒤 요소 수 증가`).
**원인**: 부모 dragstart 는 전용 MIME `text/wm-ve-component` 만 넣었고, 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 의 **출처 'null' 문서**다. Chromium 은 다른 출처가 넣은 전용 MIME 끌기 자료를 이 경계 너머로 넘겨주지 않는다(종류 목록에서도 사라진다) → 캔버스의 dragover/drop 이 끌기를 인식하지 못했다. 하네스 한계가 아니라 제품 결함.
설계(요구대로): 전용 MIME 은 유지하고, 부모가 표준 `text/plain` 에 이름 공간 값 `wm-ve-component:<검증된 id>` 를 **함께** 넣는다. 캔버스는 안전한 추출을 한곳(`core.dragComponentId`)에 모아 전용 MIME 을 우선 읽고, 없으면 text/plain 이 그 형식에 **정확히** 맞고 `core.COMPONENTS` 의 알려진 id 일 때만 받는다. dragover 때는 브라우저가 getData 를 막으므로 종류에 전용 MIME 또는 text/plain 이 있으면 끌기로 인식해 놓기 표시를 보인다(`core.isComponentDrag`). drop 때 id 가 틀리거나 임의 외부 글이면 아무것도 넣지 않고 표식도 남기지 않는다. sandbox(allow-same-origin 추가 없음)·CSP·메시지 검증·저장 검증·"추가" 버튼 대안은 그대로.
RED — `node --test test/visual-editor.test.js` (새 테스트 3건 추가 뒤)
```
✖ core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: actual: undefined, expected: 'text/wm-ve-component'
✖ visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류)
TypeError [Error]: core.componentDragText is not a function
✖ wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다
AssertionError [ERR_ASSERTION]: dragover preventDefault(놓기 허용) actual: false, expected: true
ℹ tests 23
ℹ pass 20
ℹ fail 3
```
세 번째는 **동작 테스트**다: 작은 가짜 DOM(요소 트리·속성·classList·innerHTML 파서/직렬화·querySelectorAll/closest·getBoundingClientRect·template.content·elementFromPoint·parent.postMessage)을 만들어 실제 `wm-ve-canvas.js` 를 vm 에서 부트하고, 전용 MIME 이 **없는** DataTransfer(`types: ['text/plain']`, drop 때 `text/plain = 'wm-ve-component:paragraph'`)로 dragover/drop 이벤트를 쏜다. 확인: dragover 가 기본 동작을 막고 놓기 표시를 보임 → drop 뒤 본문에 문단 **하나**가 h1 앞에 들어가고 선택되며 부모에 `changed` 본문(표식 없음)이 한 번 감 → 임의 글(`https://…`, `<img … onerror>`)·모르는 id(`wm-ve-component:evil`)·정확히 맞지 않는 값(`'…paragraph '`, `'paragraph'`)은 본문 불변·표식 없음·changed/error 없음(브라우저 기본 drop 은 막음) → 전용 MIME 이 남아 있으면 그 값 우선(heading → h2) → 파일/HTML 만 있는 끌기는 dragover/drop 모두 건드리지 않음. (가짜 DOM 의 innerHTML 파서가 자식 목록 참조를 그대로 돌려줘 둘째 요소가 빠지던 테스트 자체 결함을 먼저 고쳤다 — RED 가 "진짜 이유" 로 나도록.)
수정(두 저장소 byte-identical)
- `public/visual-editor-core.js`: `COMPONENT_MIME`, `componentDragText(id)`, `isComponentDrag(types)`, `dragComponentId(dt)` 추가·export(한국어 주석에 이유).
- `public/visual-editor-ui.js` 팔레트 dragstart: `setData(core.COMPONENT_MIME, id)` + `setData('text/plain', core.componentDragText(id))`.
- `public/wm-ve-canvas.js` dragover/drop: `core.isComponentDrag(e.dataTransfer.types)` 로 인식, drop 에서 `core.dragComponentId` 가 null 이면 놓기 표시만 숨기고 반환(기본 drop 차단, 삽입 없음). drop 도 `mode === 'edit'` 에서만.
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
```
ℹ tests 23
ℹ pass 23
ℹ fail 0
```
## 전체 묶음 — 끌어다 놓기 수정 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 84
ℹ pass 84
ℹ fail 0
```
(원본 52 + 직접 편집 32. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 91 · pass 91 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
## 실제 브라우저 게이트 4(살균 DnD 진단, 2026-10-28) — HTML5 끌기가 sandbox iframe 으로 넘어가지 않음 → 포인터 끌기 다리(출시 차단)
**발견**: MIME 보완(게이트 3 수정) 뒤 같은 게이트를 다시 돌렸는데 여전히 끌어다 놓기 단계에서 실패. 부모·캔버스 양쪽 window 에 살균 리스너(종류·types·defaultPrevented·text/plain·전용 MIME 존재 여부·이름 공간 일치 여부만, 실제 자료는 기록하지 않음)를 붙여 확인한 결과: **부모 dragstart 2건(text/plain·전용 MIME 모두 존재·형식 유효), 캔버스 dragenter 0·dragover 0·drop 0**. 즉 Chromium 은 HTML5 끌기 자체를 부모 문서에서 출처 'null' sandbox iframe 으로 넘기지 않는다 — dataTransfer 종류 보완으로는 해결할 수 없는 제품 결함.
**설계(요구대로, sandbox 는 그대로 — allow-same-origin 추가 없음)**: 부모(`visual-editor-ui.js`)가 팔레트 항목의 **포인터**(마우스·펜)를 직접 추적한다. pointerdown("추가" 버튼 위는 제외, 터치·보조 버튼 제외) → 포인터 캡처 → `core.POINTER_DRAG_THRESHOLD`(4px) 넘게 움직이면 끌기 시작(표시 class) → iframe 위에서도 캡처로 pointermove 를 받아 `#frame.getBoundingClientRect()`(테두리·스크롤바 보정 `clientLeft/Top/Width/Height`)와 비교 → 안쪽이면 검증된 명령 `paletteDragAt { component, x, y }`(iframe 상대 좌표), 밖으로 나가면·Escape·pointercancel·끝내기면 `paletteDragCancel` → 안에서 pointerup 이면 `paletteDropAt { component, x, y }`. 캔버스(`wm-ve-canvas.js`)는 메시지 규약(보낸 창·출처·세션·종류)을 통과한 뒤 `core.isComponentId`·`core.canvasPoint`(유한 숫자·자기 뷰포트 안)·편집 모드를 다시 검사하고, 스스로 `dropTargetAt` → `showDrop` / `insertComponent` 를 한다(부모는 DOM 위치를 지정하지 않는다). 잘못된 id·좌표·모드는 아무것도 넣지 않고 놓기 표시를 숨긴다. HTML5 DnD 는 포인터 이벤트가 없는 환경의 대체 경로로 남기되, 포인터 끌기 중에는 네이티브 dragstart 를 막아 pointercancel 로 끊기지 않게 한다. CSS 는 `.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class 만 추가(터치 스크롤·"추가" 버튼·키보드·메시지·CSP·저장 검증 불변).
RED — `node --test test/visual-editor.test.js` (새 테스트 3건 추가 뒤)
```
✖ core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증
AssertionError [ERR_ASSERTION]: paletteDragAt 는 부모→캔버스 종류
✖ visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리
AssertionError [ERR_ASSERTION]: pointerdown/move/up 처리기
✖ wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다
AssertionError [ERR_ASSERTION]: 놓기 표시 보임
ℹ tests 26
ℹ pass 23
ℹ fail 3
```
동작 테스트 내용 — 부모(가짜 DOM 위 실제 `visual-editor-ui.js`): 바깥 pointerdown → 문턱 아래 이동(메시지 없음) → 문턱 넘어 바깥(표시 class 만) → iframe 안(400,200 → `paletteDragAt {x:100,y:100}`, 봉투 wmve·세션 그대로) → 밖(`paletteDragCancel` 한 번, 계속 밖이어도 한 번) → 안 → pointerup(`paletteDropAt {x:160,y:160}`, 캡처·class 해제, 이후 이벤트 무시) · "추가" 버튼 위 pointerdown 은 끌기 아님 · 짧은 클릭 메시지 없음 · 터치·보조 버튼 무시 · Escape → 취소 뒤 up 은 놓기 아님 · pointercancel → 취소 정리 · 포인터 끌기 중 네이티브 dragstart 차단, 평소엔 HTML5 자료 유지 · 편집 끝낸 뒤 메시지 없음. 캔버스(가짜 DOM 위 실제 `wm-ve-canvas.js`, 부모 창에서 온 것처럼 message 이벤트): `paletteDragAt` → 놓기 표시(h1 위쪽 절반 → h1 앞 선, p 아래쪽 절반 → p 아래 선) · 취소 → 숨김 · 모르는 id·뷰포트 밖·문자열 좌표 → 숨김 · `paletteDropAt` → h1 앞에 문단 하나, 표식 없는 `changed` 한 번, 새 요소 선택, 표시 숨김 · 빈 곳 → 본문 끝 · 모르는 id·좌표 누락/범위 밖/문자열·빈 payload·미리보기 모드 → 본문 불변·changed/error 없음 · 다른 세션·다른 보낸 창 → 무시.
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
```
ℹ tests 26
ℹ pass 26
ℹ fail 0
```
## 전체 묶음 — 포인터 끌기 다리 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 87
ℹ pass 87
ℹ fail 0
```
(원본 52 + 직접 편집 35. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 94 · pass 94 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
## 독립 검토(읽기 전용) 차단 항목 12건 수정(2026-10-28) — 보안 7 · 규격/회귀 5
검토는 참조 저장소·Suite 양쪽 작업 트리를 읽고 아래 항목을 출시 차단으로 보고했다. 항목마다 행동·적대 테스트를 먼저 써 RED 를 잡고 최소 수정으로 GREEN 을 만들었다. 공유 파일(`lib/visual-editor.js`·`lib/gitstore.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor*.test.js`)은 두 저장소에서 byte-identical(`cmp`). `server.js`·`public/app.js`·`lib/store.js` 는 참조 저장소 변경을 Suite 블록을 보존한 채 패치했다(Suite 전용 항목 7·11 은 Suite 쪽 기록 `docs/verification/webmaker-visual-editor-0.2.0.md`).
| # | 결함 | 수정 | 테스트(RED 증거) |
|---|---|---|---|
| 1 | iframe 안 문서가 바뀌면(a/area href 등) 새 문서가 같은 contentWindow·출처 null 로 부모의 `*` 명령에서 세션을 배워 changed/hotkey 를 꾸밀 수 있음 | 캔버스가 `MessageChannel` 포트를 첫 ready 에 동봉, 이후 포트 전용(`validatePortMessage`); 부모는 첫 ready 한 번만 연결·연결 뒤 창 메시지 전면 무시·끝내기 때 포트 닫음; 캔버스는 창 메시지를 듣지 않음; a/area/제출/`input[type=image]` 클릭·submit 차단(두 모드), `<base target="_blank">`, CSP form-action none, meta refresh 비활성화 | `visual-editor-ui.js 연결 규약`(RED: `ready 와 함께 포트가 넘어온다` 실패 — 포트 없음) · `wm-ve-canvas.js 연결 규약` · `캔버스 안의 이동·제출 활성화 … 막힌다`(RED: area 클릭 defaultPrevented false) · `prepareCanvas: 이동 활성화 방어`(RED: base 에 target 없음) |
| 2 | `javascript&colon;…` 등 이름 엔티티로 감춘 스킴 통과 | `core.urlScheme`/`isDangerousUrl`(서버 lib 가 core 를 require — 한 구현): 이름·숫자 엔티티 두 겹, 제어·폭 없는 문자 제거, 모르는 엔티티가 스킴 자리에 남으면 거절 | `isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티…`(RED: `거절: "jav&#x61;script&colon;alert(1)"` 등) · `applyProps·core.safeUrlInput`(RED: `javascript&colon;alert(1)` 통과) |
| 3 | `/<head[^>]*>/` 가 주석·`<header>`·원문의 `<head>` 에 걸려 CSP·런타임이 엉뚱한 곳에 들어감 | `scanTags`(title·textarea 도 원문)로 진짜 head/html/body 를 찾아 넣고 없으면 진짜 `<head>` 생성 | `prepareCanvas: 주석·원문… 진짜 <head> 뒤에만`(RED: `주석은 그대로` 실패 — CSP 가 주석 안에 들어감) |
| 4 | 원본 전체 ↔ 본문만 비교 → 머리의 스크립트·핸들러·위험 주소·iframe/object 를 본문에 복사해도 통과 | 완성 문서 전체 ↔ 원본 전체 비교(꼬리 스크립트 중복 제거 뒤) | `validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 … 거절`(RED: `<p>본문</p><script src="/head.js"></script>` 통과) |
| 5 | 사전 검사만 지난 느린 요청이 다른 변경 도중에 씀; 소스 편집기 저장은 hash 검사 없음 | `withSiteLock`: 소스 PUT·직접 편집 PUT·버전 복원은 잠금 안에서 재검사→쓰기; AI/기획/가져오기/복사/다국어/배포 시작은 `claimJob`; 소스 PUT 선택적 `baseHash`(app.js 가 보냄) + 응답 `hash` | `사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT …`(RED: 소스 저장 200 — 직접 편집 저장이 덮어써짐) · `… AI 작업이 시작되면 … 409`(RED: 200, AI 작업 중 파일 변경) |
| 6 | 파일 쓴 뒤 기록/Git 실패를 삼키고 200 | 스냅샷(기록·HEAD) → 파일/기록 되돌리기(`store.save(prev,{restore:true})`)·`gitstore.rollbackTo` → 500; `gitRecord(...,{strict:true})` | `직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 …`(RED: 500 이지만 파일은 바뀐 채) · `… Git 버전 기록이 실패하면 …`(RED: 200, change null) |
| 8 | 홈 탭·openSite·HTML 없는 갱신이 묻지 않고 편집기를 끝냄 | `openSite` 전환 전 `close(false)`·취소 시 현재 사이트 반환, `showTab('home')` 질문, `onSiteUpdated` 가 다른 사이트/사라진 페이지에서 묻고 취소 시 유지, `renderPreview` 가 캔버스를 숨기지 않음 | `visual-editor-ui.js onSiteUpdated`(RED: 다른 사이트 갱신에 즉시 종료) · `test/visual-editor-app.test.js`(실제 app.js — RED: `VE.isActive()` actual false expected true, 취소했는데 끝남) |
| 9 | 복제가 뿌리 id 만 지움 | 자손 `[id]` 모두 제거 | `duplicate: 복제본은 … 자손의 id 도 지우고`(RED: 복제본 자손 id 1개 남음) |
| 10 | 블록 끌기에 캡처·pointercancel/blur/Escape 정리 없음 | `endBlockDrag`: 리스너·캡처·클래스·표시 정리; 손잡이 `setPointerCapture` | `기존 블록 끌기: … pointercancel·창 blur·Escape …`(RED: `setPointerCapture` 미호출) |
### RED — `node --test test/visual-editor.test.js`(수정 전 코드, 새 테스트 포함)
```
ℹ tests 37
ℹ pass 24
ℹ fail 13
```
실패 13건: 주소 엔티티 / prepareCanvas 구조 / 완성 문서 비교 / 부모 연결 규약 / onSiteUpdated / 캔버스 연결 규약 / 이동 활성화 차단 / base target·meta refresh / 복제 id / 끌기 정리 / applyProps 엔티티 / (포트 규약으로 옮긴) 포인터 다리 부모 / 캔버스 paletteDragAt — 대표 메시지 `Error: 포트가 연결되지 않음`, `AssertionError: 거절: "jav&#x61;script&colon;alert(1)"`, `AssertionError: 주석은 그대로`, `AssertionError: 거절되어야 함: <p>본문</p><script src="/head.js"></script>`.
### RED — `CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js`
```
ℹ tests 13
ℹ pass 9
ℹ fail 4
```
`actual: 200, expected: 409`(소스↔직접 편집 잠금) · `actual: "EACCES …sites/<id>.json….tmp", expected: /저장하지 못했습니다|되돌렸습니다/`(store 실패가 그대로 500 으로 새고 파일은 바뀜) · `actual: 200, expected: 500`(Git 실패를 삼킴) · `actual: 200, expected: 409`(AI 작업 시작과 겹친 저장).
### RED — `node --test test/visual-editor-app.test.js`(수정 전 `public/app.js` 스냅샷으로 실행)
```
ℹ pass 0
ℹ fail 1
AssertionError: Expected values to be strictly equal: actual: false, expected: true ← openSite(다른 사이트) 취소 뒤 VE.isActive()
```
### GREEN — 수정 뒤
```
node --test test/visual-editor.test.js ℹ tests 37 · pass 37 · fail 0
node --test test/visual-editor-server.test.js ℹ tests 13 · pass 13 · fail 0
node --test test/visual-editor-app.test.js ℹ tests 1 · pass 1 · fail 0
```
## 전체 묶음 — 독립 검토(1차) 차단 항목 수정 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
```
ℹ tests 103
ℹ pass 103
ℹ fail 0
```
(원본 52 + 직접 편집 51 = 단위 37 + 서비스 13 + 화면 전환 1. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 110 · pass 110 · fail 0. Suite 전체 `node test/run.mjs`: 테스트 파일 24개 중 실패 0 — Suite 쪽 기록 `docs/verification/webmaker-visual-editor-0.2.0.md`.) 최종 수치는 아래 "종결 검토 뒤" 절.
문법: `node --check` — `lib/visual-editor.js`·`lib/gitstore.js`·`lib/store.js`·`server.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`public/app.js` 두 저장소 모두 통과. 공유 파일 두 저장소 byte-identical(`cmp`). `git diff --check` 깨끗함.
## 수정 뒤 브라우저 게이트 재실행 실패의 원인 조사(2026-10-28) — 게이트 멱등성 결함 + auxclick 틈
- 재실행 출력: `step: "edit-text"`, 11/11 통과 뒤 `시간 안에 되지 않음: 저장 상태 dirty`. 선택·속성 창이 동작했으므로 MessagePort 연결은 정상.
- 가설 (a) **같은 저장 폴더 재사용** — 이전 37/37 실행이 이미 같은 고정 제목을 저장했다. 로컬 재현(참조 저장소 서버 + Chrome for Testing 156 + playwright-core 1.63, 고정 페이지의 h1 을 미리 같은 제목으로 둠): 게이트 진단 `editDiag.afterTyping = { events:{ input:16, beforeinput:16, keydown:5, focusout:0 }, canvas:{ tag:"h1", editable:true, hasFocus:true }, h1EqualsTyped:true }`, Escape 뒤 `veState:"ve-state clean", isDirtyApi:false, undoEnabled:false`. 즉 입력은 캔버스 h1 에 정확히 들어갔고 `changed` 도 갔지만 본문이 저장본과 같아 `history.push` 가 false → clean 유지. **제품의 dirty 규칙은 옳고(변경 없음 = clean) 게이트가 멱등하지 않았다.** 게이트는 실행마다 `브라우저에서 직접 수정한 제목 <실행 표식>` 을 쓰고 편집 전에 현재 제목과 다른지 단언한다(`editTargetDiffersFromCurrent`). 제품 변경 없음.
- 가설 (b) **가운데 클릭** — 같은 로컬 실행에서 게이트의 새 단언 `canvasNoNavigate_linkMiddleClick` 이 `popups=1`(새 페이지가 앱 출처로 열림)로 실패. 가운데 버튼은 `click` 이 아니라 `auxclick` 으로 오고 런타임은 `click` 만 막고 있었다 → 실제 틈. RED(단위, 가짜 DOM `fire('auxclick')`: `AssertionError: edit: AREA 가운데 클릭(auxclick) 차단`) → `wm-ve-canvas.js` 에 `auxclick` 캡처 리스너(이동 활성화 요소만 preventDefault) → GREEN. 실제 Chrome: `popups=0`.
- 결과(로컬 게이트, 같은 저장 폴더로 연속 2회): `status: pass`, **61/61**, 두 번째도 61/61. `handshake:{ windowMessagesFromFrame:1, readyWithPort:1, otherTypesViaWindow:[], origins:["null"] }`, `forged:{ putSeen:false, elemsBefore:28, elemsAfter:28 }`, 이동 활성화 6종 모두 `alive:true, popups:0`.
## 독립 검토 2차(종결 검토) 차단 항목 3건 수정(2026-10-28) — SVG SMIL 저장형 XSS · Git 저장 트랜잭션 재설계 · (Suite) 릴리스 승인 fail-open
| # | 결함(검토자 발견) | 수정 | 테스트 |
|---|---|---|---|
| 1 | `validateVisualSave` 가 `<svg><a href="#x"><animate attributeName="href" values="javascript:…">`·`<set … to="javascript:…">` 를 받았다 — 주소 목록은 정적 주소 속성만 보고 위험 요소 목록에 SMIL 변이 요소가 없었다(Chromium 에서 저장 페이지를 열면 href 가 javascript: 로 바뀌어 실행) | `lib/visual-editor.js`: SMIL 묶음 `animate·set·animateMotion·animateTransform·animateColor·discard·mpath`(HTML 파서가 SVG 로 읽는 SMIL 요소 전부; 대소문자·`svg:` 접두어 형태도 같은 묶음, fail-closed)을 위험 요소로 센다(원본 수까지만 — 삭제 허용, 추가·속성 변경 거절, 전용 안내문). `prepareCanvas` 는 SMIL 요소를 `wm-ve-x-<이름>`(여는·닫는 태그, 속성 보존)으로 바꾸고 `restoreFromCanvas` 가 표준 이름으로 되돌린다(브라우저가 소문자로 직렬화해도 `animateMotion` 등으로). `core.isSafeComponentHtml` 도 같은 묶음 거절 | 단위 +1(`validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL …`) |
| 2 | `recordChange` 가 커밋·브랜치·**원격 푸시 뒤** 사이트 기록을 저장; `rollbackTo` 는 soft reset(커밋 실패 뒤 인덱스 그대로); 경로가 되돌리기 boolean 무시; 성공한 푸시 뒤 기록 저장 실패가 원격만 바꾼 채 남을 수 있음 | `lib/gitstore.js` 새 `recordChangeStrict`(큐 한 항목): 스냅샷(add 전) → add·commit·변경 브랜치 → `onCommitted`(기록 저장) → `git push --atomic` 으로 main + 변경 브랜치 함께(마지막 단계, 그 뒤 상태 변경 없음) / 실패 → `rollbackLocal`: `reset --mixed <이전 HEAD>`(커밋 되감기 + 인덱스 비움), 첫 커밋 전이면 브랜치 삭제 + `read-tree --empty`, 변경 브랜치 삭제/복원, `onRollback`(페이지 원본·`store.save(prev,{restore:true})`), **검증**(HEAD·인덱스·브랜치·작업 트리 경로 ⊆ 저장 전·페이지 바이트·기록) → `{ok, problems, notes}`. `server.js` PUT /visual 은 `rolledBack` 을 검증 결과로만 보내고 실패 때 `rolledBack:false`+`rollbackProblems`+"상태를 확신할 수 없습니다". 강제 푸시 되돌리기 없음. 푸시 전에 저장되는 기록은 `pushed:false, remote:'pending'`(원격 반영 미주장) 이고 `reconcilePushes`(저장 뒤·기동 때·다음 기록 때)가 `ls-remote` 로 확인한 뒤에만 `verified`. `gitRecord` strict 옵션·`headCommit`·`changeBranchName` 제거(다른 호출자는 `recordChange` 그대로) | 서비스 +2(진짜 bare 원격 + pre-receive 거부 훅/pre-commit 훅/기록 폴더 읽기 전용; gitstore 직접 호출 — 첫 커밋 전 되감기·문제 보고 때 ok:false) + 기존 store/Git 실패 테스트에 `rolledBack:true`·인덱스·브랜치 단언 |
| 3 | (Suite) `hal/core.approveRelease` 가 명시적 false 만 거절 — checks 없음·testsPassed null(--skip-tests)·dirty 미기록도 승인 | Suite `hal/core.releaseApprovalProblem`: `testsPassed === true`·`dirty === false`·`provenanceClean === true` 모두 필수(approveRelease·registerRelease approve:true·운영자 API·`release.mjs register --approve` 가 같은 규칙) | Suite `test/release-provenance.test.mjs` +3 |
### RED — `node --test test/visual-editor.test.js`(수정 전 코드, 새 테스트 포함)
```
✖ validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(…)는 … 거절하고 …
AssertionError [ERR_ASSERTION]: 거절되어야 함: …<svg><a href="#x"><text>hi</text></a><animate attributeName="href" values="javascript:alert(1)" begin="0s" xlink:href="#a"/></svg>
```
### RED — `CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js`(수정 전 코드)
```
ℹ tests 14 · pass 11 · fail 3
AssertionError: 검증된 되돌리기: {"error":"버전 기록(Git)·사이트 기록을 남기지 못해 저장을 취소했습니다 (EACCES). … 일부 되돌리기에 실패했으니 관리자에게 알리세요 …","rolledBack":false} ← Git 실패 테스트: 옛 rollbackTo 가 false 를 돌려주는데 상태는 멀쩡(판정 근거 없음)
AssertionError: {"refs/heads/sites/<id>/changes/0001-create":…,"…/0002-visual-edit":…,"…/main":…} ← 원격 트랜잭션 테스트(첫 단언에서 멈춤; 옛 코드는 pre-receive 거부를 200 + rec.error 로 삼켰다)
```
### RED — Suite `node test/release-provenance.test.mjs`(수정 전 hal/core·release.mjs)
```
✘ hal/core.approveRelease·registerRelease(approve:true): … → Error: checks 없음 (approveRelease) (승인됨)
✘ HAL 운영자 API POST /ops/api/releases/:version/approve … → Error: 7.7.0: 거절 200 {"ok":true,"release":{…}}
✘ release.mjs register --approve: testsPassed … → Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0
✘ 통과 4 · 실패 3
```
### GREEN — 수정 뒤
```
node --test test/visual-editor.test.js ℹ tests 38 · pass 38 · fail 0
node --test test/visual-editor-server.test.js ℹ tests 16 · pass 16 · fail 0 (원격 트랜잭션: 성공 때 main·변경 브랜치 함께 갱신·기록은 pending → ref 확인 뒤 verified, 거부/커밋 실패/기록 실패 세 경로 모두 원격 ref 불변·rolledBack:true·재시도 200, 기동 조정)
Suite: node test/release-provenance.test.mjs ✔ 통과 7 · 실패 0
```
### 원격 반영 기록의 보수성(종결 검토 후속) — RED → GREEN
- 검사: 푸시가 원격에 닿는 순간(pre-receive 훅)에 사이트 기록 파일을 복사해 보니, 1차 구현은 푸시 **전** 에 저장한 기록이 이미 `pushed:true` 였다(RED: `AssertionError: 푸시 전 기록은 원격 반영을 주장하지 않는다: {"no":2,"kind":"visual-edit",…,"pushed":true,"error":null,…}`). 프로세스가 그 사이에 죽으면 "원격에 있다" 는 거짓 기록이 남는다.
- 수정: 기록은 `pushed:false, remote:'pending'` 으로 저장·응답하고(푸시 뒤 어떤 저장도 하지 않는다), 트랜잭션 밖의 `reconcilePushes` 가 잠금 안에서 기록을 다시 읽어 `ls-remote` 로 **원격 ref 에서 확인** 한 뒤에만 `pushed:true, remote:'verified', verifiedAt` 로 바꾼다. 원격에 없으면(크래시 창) 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인하고 변경 브랜치들 + main 을 한 번의 원자적 푸시로 올린 뒤 다시 확인한다. 호출: 저장 응답 뒤(setImmediate)·서버 기동 때(pending 이 있는 사이트)·다음 기록 때(옛 retryPush 자리). 조정의 저장은 `{restore:true}` 라 Suite rev 를 올리지 않는다.
- GREEN(서비스 테스트): 훅이 복사한 푸시 순간의 기록 `pushed:false·remote:'pending'`, 응답도 pending, 조정 뒤 `verified`(푸시 1회 — 확인만 하고 다시 푸시하지 않음); **기동 조정**: 기록을 pending 으로 되돌리고 원격 변경 브랜치 삭제·main 을 한 커밋 전으로 되돌린 뒤 같은 데이터로 재기동 → 원자적 푸시 한 번으로 브랜치·main 복구, ref 확인 뒤 `verified`, 기록 저장되고 rev 불변; gitstore 직접 호출: 거부(REJECT)면 pending + 사유, 해제 뒤 푸시·확인, 이미 올라간 기록은 확인만(푸시 로그 2건 = 거부 1 + 성공 1).
설계 메모: 되돌리기에 `reset --hard` 를 쓰지 않은 이유 — 작업 트리는 사이트 폴더 전체이고 저장과 무관한 커밋되지 않은 파일(이전 비엄격 기록 실패 등)이 있을 수 있어 `--hard` 는 그것을 지운다. 저장이 바꾼 것은 페이지 한 파일(원본 바이트로 복원)과 커밋·인덱스·브랜치뿐이므로 `--mixed` + 페이지 복원 + "작업 트리 변경 경로 ⊆ 저장 전 경로" 검증이 같은 보장을 준다. 판정은 명령 성공 여부가 아니라 실제 상태로 한다(읽기 전용 저장소에서 add 가 실패하면 reset 도 실패하지만 상태는 저장 전과 같아 ok — notes 에 명령 오류). 성공한 푸시 뒤에는 트랜잭션 안에서 어떤 저장도 하지 않는다 — 기록은 보수적(pending)으로 남고, 별도 조정 단계가 원격 ref 로 확인한 뒤에만 verified 로 바꾼다. 크래시 창(기록 저장과 푸시 사이에 프로세스 종료)에는 기록이 pending·로컬 커밋과 변경 브랜치가 남아 기동 조정이 원자적으로 올린다(거짓 "원격 반영" 기록 없음).
## 독립 검토 3차(최종 검토) 차단 항목 4건 수정(2026-10-28) — (Suite) 승인 불변 · 사이트 변경 잠금 전면 감사 · 트랜잭션 완결성·모호한 푸시 판정 · 문서 수치
| # | 결함(검토자 발견) | 수정 | 테스트 |
|---|---|---|---|
| 1 | (Suite) `registerRelease` upsert 가 승인된 버전의 checks·artifact 를 바꾸면서 approved 유지 | 승인된 버전은 같은 내용 재등록만 멱등, 다르면 `release_immutable`(변경 없음); 미승인 버전의 변경 재등록은 registered·승인 미승계; CLI 거부 종료 1 | Suite provenance +2(core 8가지 × approve true/false readback 불변, CLI) |
| 2 | 라우터가 요청 시작 때 읽은 기록으로 본문을 기다린 뒤 처리·저장(claimJob·다국어 설정·언어 폴더·복원이 기다리기 전 기록 사용) | 변경 요청은 본문을 잠금 밖에서 메모 읽기 → 잠금 안에서 기록 재적재 → 짧은 경로는 처리기 전체 잠금, 긴 경로는 재적재한 기록으로 처리(claimJob 까지 await 없음); 재진입 잠금(AsyncLocalStorage)·`detached()`; changeLanguages 는 claimJob 먼저 | 서비스 +1(설정 저장·복원·작업 시작 vs 직접 편집 저장의 결정적 경쟁) |
| 3 | `recordChangeStrict` 의 initSite·스냅샷·원격 설정이 try 밖(실패해도 onRollback 없음); 푸시 클라이언트 오류를 무조건 실패로 보고 되돌림(원격이 받았을 수 있음) | 모두 트랜잭션 안(`prepare`), 스냅샷에 repoExisted·indexClean, 새로 만든 저장소는 되돌릴 때 삭제, 스냅샷 전 실패는 ok:false; 푸시 전 원격 ref 를 읽고 오류 뒤 다시 읽어 accepted(성공)/unchanged(되돌림 + 원격 재검증)/ambiguous(pending 유지, 200 + `remoteState`) | 서비스 +1(git 래퍼: FAIL_AFTER·MIXED·FLAKY·DOWN) + 직접 호출 준비 단계 실패 |
| 4 | 문서 합계 모순(106/113·107/114) | 현재 증거로 통일 | — |
| 5 | 배경 AI 작업이 자기 사이트 객체를 나중에 저장해 작업 중 허용되는 짧은 변경(기본 사항 PATCH·모델 등급·도메인·첨부·검수·모듈 — 실행 중 검사 없음)을 지움 | `lib/store.js` 저장소 불변식: 읽은 객체마다 `__base`(항목별 JSON + `saveId`)를 기억, 저장 때 디스크 `saveId` 가 다르면 이 객체가 바꾼 최상위 항목만 디스크 최신 기록 위에 합침(restore 제외, 조정은 keepRev); 취소·상태 경로 불변, 새 잠금 없음 | 서비스 +1(작업 중 PATCH 제목·PUT 등급 → 중단 → 종료 저장 뒤 유지) |
| 6 | 같은 최상위 항목을 둘 다 바꾸면 마지막 저장자가 이겨 먼저 쓴 값이 사라짐(작업 중 다시 푸시의 git.changes 반영 상태 vs 작업 종료의 새 버전 추가) | `lib/store.js` 잎 단위 3자 병합 + 같은 잎을 다르게 바꾼 경우 `SaveConflict`(site_save_conflict, 409, fields)로 거절·디스크 보존; `store.update`·`store.adopt`; 서버 `saveJob(site, reapply)`(배경 작업의 모든 저장, 실패 기록 포함)·`reapplyCommand`/`reapplyFields`; `gitRecord` 충돌 때 버전만 추가; 다시 푸시·조정은 `applyPushState` 를 `store.update` 로 적용 | 서비스 +2(저장소 규칙 6가지; 실제 서버 — git 래퍼로 commit 2초 지연 → 작업 종료의 버전 기록 창에 다시 푸시) |
### RED(수정 전 코드)
```
Suite: ✘ hal/core.registerRelease 불변 승인 … Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…} ← 재등록이 승인된 행을 바꿈
Suite: ✘ HAL CLI register-release … 기대 approved_by "test" 실제 "test2"
W: ✖ 사이트 변경 잠금(전체 감사) … AssertionError: 설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"] ← 설정 저장이 본문을 기다리는 사이 끝난 직접 편집 버전을 지움
W: ✖ 원자적 푸시의 모호한 클라이언트 오류 … AssertionError: 원격이 받았으면 성공: {"error":"원격 소스 보관소(Git)에 올리지 못해 저장을 취소했습니다 (fatal: the remote end hung up unexpectedly (test)) …","rolledBack":true,"stage":"push"} ← 원격이 받았는데 되돌림
W: ✖ gitstore.recordChangeStrict(직접 호출) … AssertionError: 준비 단계 실패도 트랜잭션 안: {"message":"…/git/s1: Permission denied","gitDirExists":false,"page":"<p>b</p>"} ← stage·rollback 없음, 페이지 미복원
W: ✖ 배경 AI 작업이 나중에 자기 사이트 객체를 저장해도 … AssertionError: 작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"} ← 작업 중 바꾼 제목이 종료 저장에 지워짐
W: ✖ 배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경 … AssertionError: 다시 푸시가 쓴 확인 시각이 그대로다(…): {"kept":"…46.004Z","retry":"…45.975Z"} ← 작업 종료가 덮어쓰고 배경 재확인이 다른 시각을 씀(최상위 항목 합치기만 있던 코드)
W: ✖ store.save 합치기 규칙 … TypeError: store.update is not a function / 같은 잎을 둘 다 다르게 바꿔도 거절되지 않음
중간(저장소를 fail-closed 로 바꾼 직후): ✖ … AssertionError: {"error":"사이트 기록 저장 충돌: … (git.changes) …"} ← 충돌이 "다시 푸시" 경로에 드러남(덮어쓰기 없음) → 다시 푸시·조정을 store.update 경로로 바꿈
```
### GREEN — 수정 뒤
```
node --test test/visual-editor-server.test.js ℹ tests 21 · pass 21 · fail 0 (저장소 합치기 뒤: 되돌리기 검증은 저장 표식 saveId 도 제외하고 비교; 같은 잎 동시 변경은 거절·디스크 보존·작업은 최신 기록 위에 재적용)
Suite: node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0
```
## 독립 검토 4차 차단 항목 4건 수정(2026-10-28) — (Suite) artifact 설치 불변 · 읽기 요청 부작용 · 폐기 잠금·표식 · svg/math 안 title 파서 차이
| # | 결함(검토자 발견) | 수정 | 테스트 |
|---|---|---|---|
| 1 | (Suite) 두 CLI 가 DB 불변 검사 전에 버전 폴더에 복사해 승인된 artifact 바이트가 덮임; HAL CLI 는 해시 미검증 | `hal/core.installArtifacts`: 바이트 해시·크기 검증 → 승인된 버전은 메타데이터·설치 파일 해시가 같을 때만 멱등(복사 없음) / 다르면 `release_immutable`(파일·DB 불변) → 새 설치는 임시 파일 → 해시 재확인 → rename; 두 CLI 모두 설치 → 등록 | Suite provenance +2(HAL CLI·release.mjs) |
| 2 | GET/HEAD/OPTIONS 가 x-ui-lang 으로 잠금 밖 store.save | 읽기 메서드는 저장소를 건드리지 않음; 언어는 변경 요청의 잠금 안 재적재에서만 저장 | 서비스 +1 |
| 3 | 폐기가 잠금·표식 없이 긴 정리를 기다림; 실패 기록을 오래된 객체로 저장 | 잠금 안 재검사 + 표식(token) 원자적 claim → 잠금 밖 정리 → 다시 잠금·같은 표식 확인 뒤 **임시 이름으로 옮김(되돌릴 수 있음) → `beforeIrreversible` 훅(Suite 포털 연결 해제) → 기록 unlink(되돌릴 수 없는 한 단계, 실패면 되돌리기 + `compensate`) → 최선의 뒷정리(실패는 warnings·감사)**; 표식 중 변경 요청·claimJob 409; 실패는 표식 해제 + `store.update` 로 deleteFailed{recoveryRequired} | 서비스 +1(가짜 ssh 2초: 5종 409, 성공·실패·다른 사이트); Suite 모듈 `test/ariweb.test.js` +3(정리 실패 → 연결 유지, 연결 해제 실패 → 옮긴 것 되돌림, 뒷정리 실패 → 경고·남은 폴더) |
| 4 | 스캐너가 svg/math 안 `<title>` 을 RCDATA 로 읽어 `<svg><title><script>` 통과(브라우저는 실행) | `scanTags` 외래 콘텐츠 깊이 — svg/math 안에서는 원문 규칙 없음(통합 지점도 요소로, fail-closed) | 단위 +1(15 변형·닫히지 않은 title·정상 SVG·캔버스 출력) |
### RED(수정 전 코드)
```
W 단위: ✖ scanTags 파서 차이(저장형 XSS) … AssertionError: 거절되어야 함: …<svg><title><script>alert(1)</script></title></svg>
W 서비스: ✖ 읽기 요청은 부작용이 없다 … AssertionError: 읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)
W 서비스: ✖ 사이트 폐기는 잠금 안에서 … AssertionError: #0 폐기 안내 ← 폐기 중 직접 편집 저장이 "폐기" 로 거절되지 않음
Suite: ✘ artifact 설치 불변·fail-closed(HAL CLI register-release) … 기대 "artifact-A-bytes" 실제 "artifact-B-bytes-different" ← 승인된 artifact 가 덮임
Suite: ✘ artifact 설치 불변·fail-closed(scripts/release.mjs register) … 기대 "rel-A" 실제 "rel-B-other"
```
### GREEN — 수정 뒤
```
node --test test/visual-editor.test.js ℹ tests 39 · pass 39 · fail 0
node --test test/visual-editor-server.test.js ℹ tests 23 · pass 23 · fail 0
Suite: node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0
```
## 전체 묶음 — 독립 검토 4차 뒤(현재 증거)
`CLAUDE_BIN=<가짜 exit 1> npm test`
```
ℹ tests 115
ℹ pass 115
ℹ fail 0
```
(원본 52 + 직접 편집 63 = 단위 39 + 서비스 23 + 화면 전환 1. Suite `modules/webmaker` 같은 명령: tests 125 · pass 125 · fail 0(Suite 전용 `test/ariweb.test.js` 10 포함). Suite 전체 24개 파일 실패 0 — release-provenance 11.) 실제 Chrome 게이트는 61/61 그대로(이번 4건은 게이트가 보는 화면 동작을 바꾸지 않는다).
## 전체 묶음 — 최종 검토(3차) + 잎 단위 저장소 합치기 뒤
`CLAUDE_BIN=<가짜 exit 1> npm test`
```
ℹ tests 112
ℹ pass 112
ℹ fail 0
```
(원본 52 + 직접 편집 60 = 단위 38 + 서비스 21 + 화면 전환 1. Suite `modules/webmaker` 같은 명령: tests 119 · pass 119 · fail 0. Suite 전체 `CLAUDE_BIN=<가짜 exit 1> node test/run.mjs`: 테스트 파일 24개 중 실패 0 — release-provenance 7·webmaker-visual-editor 5 포함, 생성된 `local-e2e-2026-10-28.{md,json}` 은 삭제.) 문법 `node --check`(server.js·lib/gitstore.js·lib/visual-editor.js·core·테스트) 두 저장소 통과, 공유 파일 byte-identical(`cmp`), Suite `server.js` 차이 115줄(Suite 블록만), `git diff --check` 깨끗함. Suite 도입 기록 재생성: 184 파일·수정 8·workingTreeDirty=true(의도 — Neo 가 참조 저장소를 커밋한 뒤 다시 가져온다).
## 브라우저 검증 상태
- **실제 브라우저 검증은 수행되었다**(Neo 의 게이트 — 1·2차 시각 게이트 1280×633·390×844, 3·4차 Playwright 자동 게이트, 5차 네이티브 포인터 순서 게이트 **37/37 통과**: 직접 편집 열림·선택·글 편집·실행 취소/다시 실행·Alt+↑/↓·팔레트 포인터 끌어다 놓기(놓기 표시 → 문단 삽입)·저장 PUT 2xx·저장 파일(편집 제목·추가 문단·원본 스크립트 보존·표식 없음)·390×844 배치). 이 37/37 은 **독립 검토(1차) 차단 항목 수정 전** 의 결과다.
- **수정 뒤 브라우저 재실행 — 완료**: Neo 의 첫 재실행은 게이트 멱등성 결함으로 `edit-text` 에서 멈췄다(위 절). 고친 게이트(실행마다 다른 제목, 연결 규약·이동 활성화 6종·위조 창 메시지 단언 추가)는 이 PC 의 Chrome 156 에서 61/61(연속 2회), **Neo 호스트의 실제 Chrome 에서도 61/61 통과**(2026-10-28): 직접 편집 열림(ready + 포트 → 본문 수신)·선택·글 편집·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·파일·390×844 + 연결 규약·이동 활성화 6종·위조 창 메시지. 종결 검토 3건(서버 검증·Git·HAL)은 브라우저 동작을 바꾸지 않아 추가 브라우저 재실행은 필요 없다.
- 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면, 운영 서버 배포, Chrome 외 브라우저. Node 에서는 런타임 파일의 문법 검사·`eval`/`new Function` 부재·정적 구조·CSS 배치 계약·가짜 DOM 위 실제 런타임/app.js 실행(연결·전환·끌기·놓기)을 확인한다.