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"        // ← 이 필드도 문제!
}

createdstatusObsidian 로컬에서만 사용하는 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 기능이 복구되었습니다.

이 버그는 유닛 테스트에서는 발견할 수 없었습니다. 왜냐하면:

  1. 유닛 테스트에서는 Obsidian의 metadataCache를 mock으로 대체하는데, 실제 환경에서 Obsidian이 자동으로 추가하는 created, status 같은 필드를 재현하지 못했습니다.
  2. 실제 API 호출 없이는 422 에러를 확인할 수 없었습니다.
  3. 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. 참고 자료