Obsidian 플러그인을 만들면서 유닛 테스트를 꽤 촘촘하게 짜놨는데도, 실사용에서만 터지는 버그가 계속 나왔다. 한 명령이 끝나면 다른 명령이 자동으로 이어지는지, 채팅창에 카드가 뜬 순서대로 버튼이 눌리는지 — 이런 건 함수 하나를 격리해서 테스트하는 방식으로는 애초에 재현이 안 된다. 진짜 켜져 있는 Obsidian이 필요했는데, 코드는 원격 서버에서 짜고 있었고 Obsidian을 띄울 수 있는 건 집에 있는 오래된 리눅스 PC뿐이었다.
왜 scp가 아니었나 — 파일만 옮기면 되는 게 아니었다 (Electron CDP 원격 디버깅)
그때까지는 플러그인을 고칠 때마다 GitHub에 릴리스부터 올리고, 그 릴리스를 받아서 테스트했다. 테스트 한 번에 릴리스 한 번이 드는, 거꾸로 된 순서였다. 빌드 결과물을 테스트용 볼트에 바로 넣어 실제 Obsidian에서 확인하고, 통과하면 그때 릴리스하고 싶었다.
걸림돌은 전송이었다. 코드를 짜는 원격 서버에서 집 PC로 접속할 SSH 키가 없어서, scp로 파일을 보내는 길부터 막혀 있었다. 그런데 집 PC의 Obsidian에는 이미 다른 확인 작업 때문에 원격으로 붙어 있었다. 그 연결로 파일도 보낼 수 있지 않을까 싶었고, 실제로 됐다. 필요한 건 “파일 옮기기”가 아니라 “이미 켜져 있는 앱을 프로그래밍으로 조작하기”였고, 파일 쓰기는 그 안에 포함된 일이었다.
Obsidian은 Electron 앱이다. 즉 내부적으로 Chromium을 쓰는데, Chromium은
--remote-debugging-port 옵션 하나로 Chrome DevTools Protocol(CDP)을 열어준다.
이 포트에 WebSocket으로 붙으면 그 앱의 렌더러 프로세스 안에서 임의의 JS를
실행시킬 수 있다 — 브라우저 개발자 도구가 콘솔에서 하는 일을 원격에서 스크립트로
그대로 할 수 있는 셈이다. app.workspace, app.vault 같은 Obsidian의 전역
객체에도 그대로 접근되니, 파일 읽기/쓰기부터 플러그인 인스턴스 조작까지 전부
이 한 채널로 가능했다.
첫 삽질 — Restricted Mode (Obsidian 커뮤니티 플러그인 제한 모드)
CDP로 붙어서 플러그인 파일 세 개(main.js, manifest.json, styles.css)를 플러그인 폴더에
넣고, 켜진 플러그인 목록에 이름도 추가한 뒤 로드를 시켰다. 아무 일도 일어나지 않았다. 로드
함수는 오류 하나 없이 실패만 돌려줬다. 한참 헤매다 커뮤니티 플러그인 기능 자체가 켜져 있는지
물어보고서야 원인을 알았다 — **제한 모드(Restricted Mode)**였다. 새로 만든 볼트는 커뮤니티
플러그인을 꺼 둔 제한 모드로 시작하고, 이 모드에선 파일이 제자리에 있어도 로드 자체를 안 한다.
커뮤니티 플러그인을 켜는 호출을 먼저 하고 나서야 로드됐다. 한 번 켜 두면 그 볼트 설정에
남아서, 다음부터는 다시 할 필요가 없었다.
scp 없이 배포하기 — base64를 WebSocket에 실어 보냈다 (WebSocket 프레임 길이)
파일을 원격 PC에 넣는 일도 CDP로 해결했다. 렌더러 프로세스 안에서 실행되는 JS는 Node의 fs 모듈에
접근할 수 있으니, 로컬에서 빌드한 main.js를 base64로 인코딩해서 원격
스크립트의 문자열 리터럴로 통째로 박아넣고, 원격에서 fs.writeFileSync로
디코딩해 저장하면 된다. scp 세션을 따로 열 필요가 없어졌다 — 배포와 조작이
전부 CDP 하나로 끝났다.
문제는 main.js를 base64로 바꾸면 약 380KB가 된다는 것이었다. 처음 짠 WebSocket 프레임 구현은
65,535바이트까지만 처리하고 있었다. WebSocket은 125바이트까지는 길이를 한 바이트에,
65,535바이트까지는 2바이트 확장 길이에, 그보다 크면 8바이트 확장 길이에 담도록 정해
두었는데, 마지막 경우를 빼먹은 것이다. 380KB는 정확히 그 경우였다. 표준을 끝까지 구현하지 않은 자업자득이었고, 8바이트 길이를 넣고 나서야 배포가
안정됐다.
하나가 더 있었다. 원격에서 비동기 함수를 실행하고 결과를 받으려면, 실행 요청에 “프로미스가
끝날 때까지 기다리라”는 옵션(awaitPromise)을 줘야 했다. 이걸 빼면 결과 대신 빈 객체가
돌아온다. 플러그인을 켜는 함수가 아직 돌지도 않았는데 조용히 실패한 것처럼 보여서, 한동안
엉뚱한 곳을 들여다봤다.
화면까지 조작하기 — 채팅 입력창과 실행 버튼 (Obsidian UI 자동화)
파일 배포가 되고 나니 남은 건 실제 사용 흐름 재현이었다. 채팅 뷰의
인스턴스를 app.workspace.getLeavesOfType(...)로 찾아서, 입력창 엘리먼트에
직접 값을 넣고 전송 함수를 호출하는 식으로 노트 정리 명령(/ingest)을 실행시켰다.
합성 키보드 이벤트를 흉내 내는 것보다, 뷰가 이미 노출하고 있는 메서드를 직접
부르는 쪽이 훨씬 안정적이었다.
버튼 조작에서는 두 번 걸려 넘어졌다. 처음에는 실행 버튼을 “이 클래스를 가진 버튼 중 마지막 것”으로 찾았는데, 제안 카드 여러 개를 한꺼번에 실행하는 버튼과 카드마다 달린 개별 실행 버튼이 같은 클래스를 쓰고 있었다. 그래서 일괄 실행 대신 두 번째 병합 카드의 개별 버튼이 눌렸고, 병합 두 건 중 한 건만 실행됐다. 작업 기록에 흔적이 없고 노트 개수도 한 건만 줄어 있어서야 알아챘다. 그 뒤로는 버튼을 글자 그대로 찾았다.
15시간쯤 뒤에는 반대 문제를 만났다. 카드 아래의 “일괄 실행” 버튼은 체크된 항목이 하나도 없으면 아무것도 실행하지 않고 알림만 잠깐 띄운다. 화면 아래쪽에 붙은 “전체 실행” 막대는 아무것도 고르지 않으면 전부 실행하는데, 카드의 버튼은 그렇지 않았다. 그 알림은 원격 조작으로는 보이지 않으니, 클릭은 성공했다고 나오는데 20초가 지나도 병합이 안 된 채였다. “전체 선택”을 먼저 누른 뒤에야 실행됐다.
남는 생각
이 환경은 정식 E2E 테스트 프레임워크도 아니고, 그렇다고 매번 사람이 붙어서 하는 수동 테스트도 아니다. 그 중간쯤 — 스크립트로 실제 앱 인스턴스 하나를 붙잡고 흔들어보는 방식이다. 유닛 테스트가 구조적으로 볼 수 없는 것들(명령이 자동으로 이어지는 타이밍, 화면에 뜬 카드의 실제 텍스트, 같은 클래스를 쓰는 버튼들과 버튼마다 다른 실행 조건)을 잡아낸 건 전부 이 환경 덕분이었다. 대신 그 대가로, 테스트 도구 자체의 버그(프레임 길이, 비동기 결과 받기, 버튼 동작)를 직접 설계하고 직접 고쳐야 했다 — 기존 프레임워크를 썼다면 안 겪었을 종류의 수고였다.