Obsidian CLI 없이 플러그인 E2E 테스트하기: Electron CDP 활용법
Obsidian CLI가 Early Access(v1.12+)라 사용할 수 없을 때, Electron의 Chrome DevTools Protocol을 활용해 플러그인 E2E 테스트를 구현한 경험을 공유합니다. 실제 버그 발견부터 11개 옵션 조합 테스트까지.
1. 문제 상황
Obsidian 플러그인을 개발하다 보면, 유닛 테스트만으로는 검증할 수 없는 영역이 있습니다. 실제 Obsidian 환경에서 파일이 생성되는지, API 호출이 정상적으로 이루어지는지, 설정 조합에 따라 동작이 달라지는지 확인하려면 E2E(End-to-End) 테스트가 필요합니다.
2026년 2월, Obsidian은 v1.12에서 공식 CLI를 출시했습니다. obsidian plugin:reload, obsidian eval 같은 명령으로 터미널에서 플러그인을 제어할 수 있게 된 것이죠.
# Obsidian CLI (v1.12+) - 이상적인 방법
obsidian plugin:reload id=my-plugin
obsidian eval "app.commands.executeCommandById('my-plugin:sync')"
obsidian dev:errors
하지만 문제가 있었습니다:
$ which obsidian
obsidian not found
$ ls ~/Library/Application\ Support/obsidian/*.asar
obsidian-1.11.7.asar
Obsidian CLI는 v1.12 Early Access(Catalyst License, $25)부터 사용 가능했고, Homebrew stable 채널은 아직 v1.11.7까지만 배포하고 있었습니다. CLI를 쓸 수 없는 상황에서, 어떻게 E2E 테스트를 할 수 있을까요?
2. 핵심 아이디어: Obsidian은 Electron 앱이다
답은 의외로 간단합니다. Obsidian은 Electron 앱이고, Electron은 Chromium 기반입니다. 그리고 Chromium은 **Chrome DevTools Protocol(CDP)**을 지원합니다.
CDP를 사용하면 외부에서 WebSocket을 통해 Obsidian 내부의 JavaScript 컨텍스트에 접근할 수 있습니다. 이것은 Obsidian CLI가 내부적으로 하는 것과 본질적으로 동일한 메커니즘입니다.
┌──────────────┐ WebSocket ┌──────────────────┐
│ Node.js │ ◄──────────────► │ Obsidian │
│ 테스트 러너 │ CDP (port 9222) │ (Electron/ │
│ │ │ Chromium) │
│ Runtime. │ │ │
│ evaluate() │ ──► JS 실행 ──► │ app.vault.* │
│ │ │ app.plugins.* │
│ │ ◄── 결과 반환 ◄── │ app.commands.* │
└──────────────┘ └──────────────────┘
Obsidian CLI vs CDP 비교
| 기능 | Obsidian CLI (v1.12+) | CDP (v1.11.7+) |
|---|---|---|
| 플러그인 리로드 | obsidian plugin:reload |
app.plugins.disablePlugin() + enablePlugin() |
| JS 실행 | obsidian eval "..." |
Runtime.evaluate via WebSocket |
| 콘솔 확인 | obsidian dev:console |
Console.enable + 이벤트 수신 |
| 에러 확인 | obsidian dev:errors |
exceptionDetails 파싱 |
| 최소 버전 | v1.12 (Early Access) | 제한 없음 (Electron이면 가능) |
| 설치 | Catalyst License 필요 | 추가 설치 불필요 |
3. 세팅: 3단계로 시작하기
Step 1: Obsidian을 디버그 모드로 실행
# 현재 Obsidian 종료
pkill -f "Obsidian"
# remote-debugging-port 플래그와 함께 재실행
/Applications/Obsidian.app/Contents/MacOS/Obsidian --remote-debugging-port=9222 &
Step 2: 연결 확인
# CDP 버전 정보 확인
$ curl -s http://localhost:9222/json/version | python3 -m json.tool
{
"Browser": "Chrome/142.0.7444.265",
"Protocol-Version": "1.3",
"User-Agent": "...obsidian/1.11.7 Chrome/142.0.7444.265 Electron/39.5.1...",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/..."
}
Step 3: 페이지 타겟 찾기
# Obsidian의 메인 페이지 타겟 ID 확인
$ curl -s http://localhost:9222/json/list | python3 -m json.tool
[
{
"id": "14746264D2A34CA61D77B805C1522A38", // ← 이 ID를 사용
"title": "새 탭 - uppinote - Obsidian v1.11.7",
"type": "page",
"url": "app://obsidian.md/index.html",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/..."
}
]
4. 첫 번째 명령 실행: Node.js로 Obsidian 제어하기
Node.js 22+에는 WebSocket이 내장되어 있어 별도 패키지 설치 없이 CDP 통신이 가능합니다.
// eval-in-obsidian.mjs
const TARGET_ID = '14746264D2A34CA61D77B805C1522A38';
const ws = new WebSocket(
`ws://localhost:9222/devtools/page/${TARGET_ID}`
);
ws.addEventListener('open', () => {
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: `(async () => {
return JSON.stringify({
vault: app.vault.getName(),
plugins: Object.keys(app.plugins.plugins),
commands: Object.keys(app.commands.commands)
.filter(c => c.includes("my-plugin"))
});
})()`,
awaitPromise: true, // ← async 함수 결과를 기다림
returnByValue: true
}
}));
});
ws.addEventListener('message', (e) => {
const result = JSON.parse(e.data);
if (result.id === 1) {
console.log(JSON.parse(result.result.result.value));
ws.close();
}
});
$ node eval-in-obsidian.mjs
{
vault: "my-vault",
plugins: ["table-editor-obsidian", "obsidian42-brat", "my-plugin"],
commands: [
"my-plugin:sync-all-from-remote",
"my-plugin:sync-all-to-remote",
"my-plugin:bidirectional-sync-all"
]
}
Obsidian의 전체 API에 접근할 수 있습니다. app.vault, app.plugins, app.commands, app.workspace — 플러그인이 할 수 있는 모든 것을 외부에서 실행할 수 있습니다.
5. 실전: 플러그인 빌드 → 배포 → 리로드 자동화
개발 중 가장 반복적인 작업은 "코드 수정 → 빌드 → 플러그인 리로드"입니다. 이를 자동화할 수 있습니다.
# 1. 빌드
npm run build
# 2. vault의 플러그인 디렉토리로 복사
cp main.js manifest.json \
"/path/to/vault/.obsidian/plugins/my-plugin/"
# 3. CDP로 플러그인 리로드
node -e "
const ws = new WebSocket('ws://localhost:9222/devtools/page/${TARGET_ID}');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: \`(async () => {
await app.plugins.disablePlugin('my-plugin');
await app.plugins.enablePlugin('my-plugin');
return 'Plugin reloaded';
})()\`,
awaitPromise: true,
returnByValue: true
}
}));
});
ws.addEventListener('message', (e) => {
const result = JSON.parse(e.data);
if (result.id === 1) {
console.log(result.result.result.value);
ws.close();
}
});
"
Plugin reloaded
Obsidian을 수동으로 재시작하거나 Cmd+P → "Reload app"을 할 필요가 없어집니다.
6. E2E 테스트 작성: 실제 API와 통신하는 테스트
이제 본격적으로 E2E 테스트를 작성해보겠습니다. 재사용 가능한 헬퍼 함수부터 만듭니다.
CDP 헬퍼 함수
// tests/e2e/run-e2e.mjs
async function findPageTarget() {
const resp = await fetch(`http://localhost:9222/json/list`);
const targets = await resp.json();
const page = targets.find(
t => t.type === 'page' && t.url.includes('obsidian')
);
if (!page) throw new Error('Obsidian page target not found');
return page.id;
}
function evalInObsidian(targetId, expression, timeout = 20000) {
const wsUrl = `ws://localhost:9222/devtools/page/${targetId}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timeout (${timeout}ms)`)),
timeout
);
const ws = new WebSocket(wsUrl);
ws.addEventListener('open', () => {
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression,
awaitPromise: true, // ← 핵심: async 결과 대기
returnByValue: true
}
}));
});
ws.addEventListener('message', (e) => {
const result = JSON.parse(e.data);
if (result.id === 1) {
clearTimeout(timer);
if (result.result?.exceptionDetails) {
resolve({ __error: result.result.exceptionDetails
.exception?.description });
} else {
try { resolve(JSON.parse(result.result.result.value)); }
catch { resolve(result.result.result.value); }
}
ws.close();
}
});
});
}
Obsidian 내부에서 실행되는 헬퍼
테스트에서 반복적으로 사용하는 Obsidian 내부 로직은 문자열 헬퍼로 추출합니다.
// Obsidian의 metadataCache는 비동기적으로 업데이트됨
// vault.modify() 후 즉시 frontmatter를 읽으면 구 값이 반환될 수 있음
const HELPERS = `
function waitForCache(file, key, val, maxWait = 3000) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
const fm = app.metadataCache
.getFileCache(file)?.frontmatter;
if (fm && String(fm[key]) === String(val))
return resolve(true);
if (Date.now() - start > maxWait)
return resolve(false); // ← 타임아웃
setTimeout(check, 100); // ← 100ms 간격 폴링
})();
});
}
async function openAndActivate(path) {
const file = app.vault.getAbstractFileByPath(path);
const leaf = app.workspace.getLeaf(false);
await leaf.openFile(file); // ← 파일 열기
await new Promise(r => setTimeout(r, 800));
return file;
}
async function modifyCount(file, newCount) {
let content = await app.vault.read(file);
content = content.replace(
/Count: \\d+/, 'Count: ' + newCount
);
await app.vault.modify(file, content);
await waitForCache(file, 'Count', newCount); // ← 캐시 동기화 대기
}
function setMode(mode, autoFormula) {
const p = getPlugin();
p.settings.conflictResolution = mode;
if (autoFormula !== undefined)
p.settings.autoSyncFormulas = autoFormula;
p.conflictResolver.updateSettings(p.settings);
}
`;
핵심 발견:
app.vault.modify()후app.metadataCache가 즉시 업데이트되지 않습니다. E2E 테스트에서 가장 자주 만나는 함정으로, polling으로 캐시 동기화를 대기해야 합니다.
실제 테스트 케이스
// Pull 테스트: 외부 데이터 → Obsidian
await test('from-remote / all', async () => {
const r = await run(`(async () => {
${HELPERS}
await getPlugin().syncQueue.enqueue('from-remote', 'all');
await new Promise(r => setTimeout(r, 5000));
const files = app.vault.getFiles()
.filter(f => f.path.startsWith(
getPlugin().settings.folderPath + '/'
));
return JSON.stringify({ fileCount: files.length });
})()`, 15000);
return { pass: r.fileCount >= 8, detail: `${r.fileCount} files` };
});
// Push 테스트: Obsidian → 외부 (obsidian-wins 모드)
await test('to-remote / all / obsidian-wins', async () => {
await doReset();
const r = await run(`(async () => {
${HELPERS}
setMode('obsidian-wins');
const file = app.vault.getAbstractFileByPath(
getPlugin().settings.folderPath + '/E2E-Push-Test.md'
);
await modifyCount(file, 555); // ← 로컬 값 변경
await getPlugin().syncQueue
.enqueue('to-remote', 'all');
await new Promise(r => setTimeout(r, 5000));
const fields = await fetchRecord('${testRecordIds[1]}');
setMode('manual');
return JSON.stringify({
pass: fields.Count === 555, // ← 원격에 반영 확인
count: fields.Count
});
})()`, 25000);
return { pass: r.pass, detail: \`Count=\${r.count}\` };
});
// 양방향 테스트: Push → Formula 계산 → Pull back
await test('bidirectional / all / autoSyncFormulas=true',
async () => {
await doReset();
const r = await run(`(async () => {
${HELPERS}
setMode('obsidian-wins', true); // ← formula 자동 동기화 ON
const file = app.vault.getAbstractFileByPath(
getPlugin().settings.folderPath
+ '/E2E-Bidir-Test.md'
);
await modifyCount(file, 450); // ← Count를 450으로 변경
await getPlugin().syncQueue
.enqueue('bidirectional', 'all');
await new Promise(r => setTimeout(r, 10000));
const updated = await app.vault.read(file);
const calMatch = updated.match(/Cal: (\\d+)/);
const localCal = calMatch
? parseInt(calMatch[1]) : 0;
setMode('manual', false);
return JSON.stringify({
pass: localCal === 900, // ← 450 * 2 = 900 확인
localCal
});
})()`, 30000);
return {
pass: r.pass,
detail: \`Cal=\${r.localCal} (expected 900)\`
};
}
);
7. E2E 테스트로 실제 버그 발견
이 테스트를 실행하면서 유닛 테스트로는 발견할 수 없었던 실제 버그를 찾았습니다.
증상
Push(Obsidian → 외부 서비스) 실행 시 모든 레코드 업데이트가 422 에러로 실패했습니다.
batchResults: [
{ success: false, recordId: "rec...", error: "Request failed, status 422" },
{ success: false, recordId: "rec...", error: "Request failed, status 422" },
{ success: false, recordId: "rec...", error: "Request failed, status 422" }
]
CDP로 원인 추적
CDP eval을 통해 실제로 어떤 데이터가 전송되는지 확인했습니다.
// 필드 추출 결과를 확인
const fields = plugin.frontmatterParser
.extractSyncableFields(file, cachedFields);
// 결과:
{
"Name": "E2E-Push-Test",
"Count": 555,
"Status": "Done",
"created": "2026-02-25", // ← 이 필드가 문제!
"status": "imported" // ← 이 필드도 문제!
}
created와 status는 Obsidian 로컬에서만 사용하는 frontmatter 필드인데, 외부 서비스에는 존재하지 않는 필드입니다. 외부 API는 알 수 없는 필드를 받으면 422를 반환했습니다.
근본 원인
// Before (버그): 외부 서비스에 없는 필드가 필터링되지 않음
if (cachedFields) {
const fieldInfo = cachedFields.find(f => f.name === key);
if (fieldInfo && isReadOnlyFieldType(fieldInfo.type)) {
continue; // read-only만 제외 → 외부에 없는 필드는 통과!
}
}
// After (수정): 외부 서비스에 존재하고 쓰기 가능한 필드만 허용
if (cachedFields) {
const fieldInfo = cachedFields.find(f => f.name === key);
if (!fieldInfo || isReadOnlyFieldType(fieldInfo.type)) {
continue; // ← 핵심 변경: !fieldInfo로 미존재 필드도 제외
}
}
한 글자 차이(fieldInfo && → !fieldInfo ||)로 전체 Push 기능이 복구되었습니다.
이 버그는 유닛 테스트에서는 발견할 수 없었습니다. 왜냐하면:
- 유닛 테스트에서는 Obsidian의
metadataCache를 mock으로 대체하는데, 실제 환경에서 Obsidian이 자동으로 추가하는created,status같은 필드를 재현하지 못했습니다. - 실제 API 호출 없이는 422 에러를 확인할 수 없었습니다.
- field metadata cache와의 상호작용은 통합 환경에서만 테스트 가능합니다.
8. 전체 테스트 결과: 11개 옵션 조합
최종 E2E 테스트 스위트는 모든 의미있는 옵션 조합을 커버합니다.
========================================
E2E TEST SUMMARY
========================================
PASS | from-remote / all
PASS | from-remote / current
PASS | to-remote / all / obsidian-wins
PASS | to-remote / current / obsidian-wins
PASS | to-remote / all / remote-wins
PASS | to-remote / current / remote-wins
PASS | to-remote / all / manual (conflict blocks)
PASS | bidirectional / all / autoSyncFormulas=true
PASS | bidirectional / all / autoSyncFormulas=false
PASS | bidirectional / current / autoSyncFormulas=true
PASS | bidirectional / current / autoSyncFormulas=false
Total: 11/11 passed
| 테스트 축 | 옵션 | 설명 |
|---|---|---|
| SyncMode | from-remote, to-remote, bidirectional | 데이터 흐름 방향 |
| SyncScope | all, current | 전체 vs 현재 파일 |
| ConflictResolution | obsidian-wins, remote-wins, manual | 충돌 해결 전략 |
| autoSyncFormulas | true, false | formula pull-back 여부 |
9. 핵심 교훈과 주의점
metadataCache 동기화 지연
가장 중요한 교훈입니다. Obsidian의 metadataCache는 파일 변경 후 비동기적으로 업데이트됩니다.
// ❌ 잘못된 방법: 즉시 읽기
await app.vault.modify(file, newContent);
const fm = app.metadataCache
.getFileCache(file)?.frontmatter;
// fm은 여전히 이전 값일 수 있음!
// ✅ 올바른 방법: 캐시 업데이트 대기
await app.vault.modify(file, newContent);
await waitForCache(file, 'Count', 450); // ← polling
const fm = app.metadataCache
.getFileCache(file)?.frontmatter;
// fm.Count === 450 보장
current scope와 파일 활성화
current scope 테스트는 파일이 에디터에 활성화되어 있어야 합니다. openLinkText 대신 leaf.openFile()을 사용하세요.
// ❌ 불안정: openLinkText
await app.workspace.openLinkText(file.path, '', false);
// ✅ 안정적: leaf.openFile
const leaf = app.workspace.getLeaf(false);
await leaf.openFile(file);
await new Promise(r => setTimeout(r, 800)); // ← UI 안정화 대기
테스트 격리를 위한 데이터 관리
각 테스트는 독립적으로 실행되어야 합니다. 테스트 데이터를 자동 생성하고 자동 정리하세요.
async function setup() {
// 테스트 전용 레코드 생성 (기존 데이터에 영향 없음)
const records = await createTestRecords([
{ Name: 'E2E-Pull-Test', Count: 100 },
{ Name: 'E2E-Push-Test', Count: 200 },
{ Name: 'E2E-Bidir-Test', Count: 300 }
]);
testRecordIds = records.map(r => r.id);
}
async function cleanup() {
// 테스트 레코드 삭제
await deleteRecords(testRecordIds);
// 로컬 파일 삭제
for (const name of testFileNames) {
const file = app.vault
.getAbstractFileByPath(`Sync/${name}`);
if (file) await app.vault.delete(file);
}
}
10. 베스트 프랙티스
체크리스트
- [ ] Obsidian을
--remote-debugging-port=9222로 실행 - [ ]
curl localhost:9222/json/version으로 연결 확인 - [ ] Node.js 22+의 내장 WebSocket 사용 (패키지 불필요)
- [ ]
awaitPromise: true로 async 결과 대기 - [ ]
metadataCache동기화를 위한 polling 헬퍼 구현 - [ ]
leaf.openFile()로 파일 활성화 (current scope 테스트) - [ ] 테스트 데이터 자동 생성/정리로 격리 보장
- [ ] 각 테스트 전 상태 리셋으로 의존성 제거
npm 스크립트 설정
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "node tests/e2e/run-e2e.mjs --cleanup"
}
}
# 유닛 테스트 (220개, 0.5초)
npm run test:run
# E2E 테스트 (11개, ~3분, Obsidian 실행 필요)
npm run test:e2e
11. FAQ
Q: CDP 방식은 Obsidian CLI와 비교하면 어떤 장단점이 있나요?
A: CDP는 모든 Obsidian 버전에서 작동하고 추가 비용이 없습니다. 반면 CLI는 더 간결한 인터페이스를 제공합니다. CDP는 WebSocket 연결 관리가 필요하지만, 한번 헬퍼 함수를 만들면 CLI와 동일한 수준의 편의성을 얻을 수 있습니다.
Q: CI/CD에서도 사용할 수 있나요?
A: 현재는 로컬 개발 환경 전용입니다. Obsidian은 GUI 앱이므로 headless 실행이 불가합니다. CI에서 사용하려면 Docker + Xvfb(가상 디스플레이)를 조합해야 하는데, trashhalo/obsidian-plugin-e2e-test 프로젝트가 이 접근법을 시도한 레퍼런스가 될 수 있습니다.
Q: WebdriverIO나 Playwright 대신 CDP를 직접 사용하는 이유는?
A: 두 가지 이유입니다. 첫째, 추가 의존성이 0입니다. Node.js 22+의 내장 WebSocket만 사용합니다. 둘째, Obsidian 플러그인 테스트는 UI 인터랙션보다 API 호출과 데이터 검증이 핵심이라 DOM 조작이 필요한 WebdriverIO/Playwright의 장점이 크지 않습니다.
Q: 다른 Electron 앱에도 적용 가능한가요?
A: 네. VS Code, Slack, Discord, Notion 데스크탑 등 모든 Electron 앱에 동일한 기법을 적용할 수 있습니다. --remote-debugging-port 플래그만 붙이면 됩니다.
Q: 실행 중인 Obsidian에 영향을 주나요?
A: Runtime.evaluate로 실행하는 코드는 Obsidian의 메인 스레드에서 직접 실행됩니다. 테스트 중에는 Obsidian을 수동으로 사용하지 않는 것을 권장합니다. 또한 설정 변경 테스트 후에는 원래 값으로 복원해야 합니다.
12. 참고 자료
- Chrome DevTools Protocol 공식 문서
- Electron 자동화 테스트 가이드
- Obsidian 플러그인 E2E 테스트 (WebdriverIO)
- obsidian-plugin-e2e-test (Docker + Spectron)
- Obsidian Plugin Testing Hub