E2E 스위트가 하나로 부족해진 날: Obsidian 플러그인에 두 번째 CDP 테스트 추가하기

첫 번째 CDP E2E 스위트 글의 속편. multi-config 리팩토링에서 Settings UI를 검증하기 위해 두 번째 E2E 스위트를 추가했고, 그 과정에서 51줄의 CDP 인프라 코드를 공유 모듈로 추출했습니다. 두 스위트를 하나의 Obsidian 인스턴스에서 돌리는 격리 규칙과 공유 경계 설계까지.

E2E 스위트가 하나로 부족해진 날: Obsidian 플러그인에 두 번째 CDP 테스트 추가하기

1. 두 번째 스위트가 필요했던 순간

이 글은 Obsidian CLI 없이 플러그인 E2E 테스트하기: Electron CDP 활용법 글의 속편입니다. 첫 번째 글에서는 Chrome DevTools Protocol로 Obsidian 플러그인의 싱크 파이프라인(pull from Airtable, push to Airtable, bidirectional)을 자동 테스트하는 방법을 다뤘습니다. 그 글이 나온 뒤 E2E 스위트는 한동안 잘 돌았습니다.

문제는 v0.8.0의 multi-config 리팩토링이었습니다. Settings UI가 통째로 바뀌면서, 기존에 없던 UI 요소들이 추가됐습니다.

  • Config Tab Bar: 여러 config 사이를 전환하는 수평 탭 바
  • Summary Card: 각 섹션을 collapsible card로 묶은 UI (예: "File Settings", "Sync Settings", "Airtable Credentials")
  • Status Badge: 카드마다 "Configured"/"Setup required" 같은 상태 배지
  • Collapsible Section: 클릭 시 펼쳐지는 상세 설정

이 UI 요소들은 내부 상태에 따라 정확하게 변해야 했습니다. 예를 들어 config.folderPath가 비어 있으면 badge는 "Setup required"가 떠야 하고, 채워지면 "Configured"로 바뀌어야 합니다. Bidirectional sync 토글을 켜면 관련 카드의 summary text가 업데이트돼야 합니다. 탭 전환 시 현재 config의 상태가 정확히 반영돼야 합니다.

이런 걸 unit 테스트로 하려면 Obsidian의 전체 settings panel 시스템을 mock해야 합니다. 실행 가능하지만 mock이 실제 DOM과 같은 확신을 주지 못합니다. 반대로 기존 E2E 스위트(run-e2e.mjs)에서 같이 다루면 파일 싱크와 UI 검증이 뒤섞여 한 실행에 시간이 너무 오래 걸리고, 실패 원인 파악이 어려워집니다.

결론은 두 번째 E2E 스위트를 만드는 것이었습니다. 이름은 run-settings-e2e.mjs. 목적은 하나 — Settings UI의 렌더링과 상호작용만 검증.

그리고 그 과정에서 기존 CDP 인프라의 중복이 드러났습니다. 이 글은 그 중복을 해결한 cdp-helpers.mjs 공유 모듈 이야기와, 두 개의 E2E 스위트를 같은 Obsidian 인스턴스에 대고 어떻게 돌리는지에 대한 기록입니다.

2. 첫 번째 스위트의 구조 복습

첫 번째 글에서 만든 run-e2e.mjs의 구조는 이랬습니다.

// tests/e2e/run-e2e.mjs (before shared refactor)

// --- CDP 인프라 (1) ---
async function findPageTarget() {
  const override = process.env.CDP_TARGET_ID;
  if (override) return override;

  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('No Obsidian page target 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, returnByValue: true },
      }));
    });
    ws.addEventListener('message', (e) => {
      const result = JSON.parse(e.data);
      if (result.id === 1) {
        clearTimeout(timer);
        // error handling + result parsing
      }
    });
  });
}

// --- Obsidian-side helpers (2) ---
const HELPERS = `
  function getPlugin() { return app.plugins.plugins['auto-note-importer']; }
  function waitForCache(file, key, val) { /* polling */ }
  async function openAndActivate(path) { /* leaf.openFile */ }
  // ...
`;

// --- 실제 테스트 케이스 (3) ---
await test('from-remote / all', async () => { /* ... */ });
await test('to-remote / all / obsidian-wins', async () => { /* ... */ });
// ... 11 cases

세 가지 레이어가 섞여 있습니다.

  1. CDP 인프라: findPageTarget, evalInObsidian. WebSocket 수준의 저수준 통신.
  2. Obsidian-side 헬퍼: getPlugin, waitForCache, openAndActivate. Obsidian 내부 API 래퍼(문자열로 전달됨).
  3. 테스트 케이스: 실제 검증 로직.

두 번째 스위트(run-settings-e2e.mjs)는 1번과 2번 중 1번은 완전히 같은 것을 필요로 합니다 (어차피 CDP 통신). 하지만 2번은 다른 것을 필요로 합니다 (UI 조작 헬퍼: 설정 탭 열기, 카드 쿼리, 뱃지 읽기).

3. 중복의 유혹과 이를 피한 방법

3.1 Copy-paste의 유혹

가장 빠른 길은 run-e2e.mjs를 복사해서 run-settings-e2e.mjs를 만드는 것입니다. 1번과 2번을 그대로 두고, 3번만 새 테스트 케이스로 교체.

cp tests/e2e/run-e2e.mjs tests/e2e/run-settings-e2e.mjs
# 이제 헬퍼 47줄이 두 파일에 복사됨

이게 왜 나쁜가: CDP 인프라 1번은 거의 영원히 안 바뀌지만, 가끔 바뀝니다. 예를 들어:

  • CDP 버전 변경에 따라 Runtime.evaluate 파라미터가 추가될 수 있음
  • WebSocket close 타이밍 관련 race condition fix
  • Retry 로직 추가 (Obsidian 시작 직후 타겟을 못 찾는 경우)

이런 변경이 생기면 두 파일을 모두 고쳐야 합니다. 그리고 하나만 고치면 조용한 drift가 쌓입니다. "첫 번째 스위트는 fix됐는데 두 번째 스위트는 구버전 로직을 쓰는 상황"은 특히 추적하기 어렵습니다 — 두 스위트가 각자 독립적으로 돌아가기 때문에 한쪽의 버그가 다른 쪽에는 드러나지 않습니다.

3.2 Shared module이 아닌 선택지들

Option A — npm run test:e2e가 두 스위트를 합쳐서 돌린다: 한 파일에 모든 걸 합친다. 거부. 테스트 목적이 다르고 실행 시간이 길어지며, 한 번 실패 시 나머지 디버깅이 어려움.

Option B — 테스트 프레임워크(Vitest, Playwright)로 옮긴다: 가능하지만 overhead가 큼. 현재 구조는 "Node.js 내장 WebSocket + fetch만 사용, 의존성 0"이 장점인데, Vitest를 도입하면 이 장점이 사라짐. Playwright도 마찬가지.

Option C — 공유 모듈 cdp-helpers.mjs 추출: 채택. 최소한의 변경으로 drift를 방지하고, 두 스위트 모두 기존 스타일 유지.

Option C가 이 프로젝트 scale에 맞는 적절한 선택이었습니다.

4. cdp-helpers.mjs — 공유 모듈 추출

4.1 추출 범위 결정

공유할 범위를 정해야 했습니다. 원칙은 **"변경 빈도가 낮고, 두 스위트 모두에서 동일하게 필요한 것만"**이었습니다.

레이어 공유? 이유
findPageTarget() 두 스위트 모두 필요, 로직 동일
evalInObsidian() 두 스위트 모두 필요, 로직 동일
Obsidian-side HELPERS string 스위트마다 다름 (싱크용 vs UI용)
테스트 케이스 스위트마다 다름
setup()/cleanup() 데이터 셋업 방식이 다름

1번 레이어만 공유하고 나머지는 각 스위트에 둡니다. 헬퍼 모듈이 이것저것 다 떠안으면 나중에는 또 다른 drift의 원인이 됩니다.

4.2 cdp-helpers.mjs 완성본

// tests/e2e/cdp-helpers.mjs
/**
 * Shared CDP (Chrome DevTools Protocol) helpers for E2E tests.
 *
 * Usage:
 *   import { findPageTarget, evalInObsidian } from './cdp-helpers.mjs';
 */

const CDP_PORT = process.env.CDP_PORT || 9222;

export async function findPageTarget() {
  const override = process.env.CDP_TARGET_ID;
  if (override) return override;

  const resp = await fetch(`http://localhost:${CDP_PORT}/json/list`);
  const targets = await resp.json();
  const page = targets.find(
    t => t.type === 'page' && t.url.includes('obsidian'),
  );
  if (!page) {
    throw new Error(
      'No Obsidian page target found. Is Obsidian running with --remote-debugging-port?',
    );
  }
  return page.id;
}

export function evalInObsidian(targetId, expression, timeout = 20000) {
  const wsUrl = `ws://localhost:${CDP_PORT}/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,
          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 ||
              'Unknown error',
          });
        } else {
          try {
            resolve(JSON.parse(result.result.result.value));
          } catch {
            resolve(result.result.result.value);
          }
        }
        ws.close();
      }
    });

    ws.addEventListener('error', (err) => {
      clearTimeout(timer);
      ws.close();
      reject(err);
    });
  });
}

48줄. 두 가지 개선이 같이 들어갔습니다.

첫째, CDP_PORT 환경 변수 지원. 원래 포트는 9222 하드코딩이었는데, 여러 Obsidian 인스턴스를 동시에 돌리거나 다른 포트로 띄우는 경우를 위해 process.env.CDP_PORT || 9222로 바꿨습니다. 이 작은 변경 덕분에 테스트 환경의 유연성이 훨씬 커졌습니다.

둘째, error 이벤트 핸들러 추가. 원래 코드는 openmessage만 처리했고, 연결 실패 시에는 timeout에 의존했습니다. error 핸들러를 추가하니 20초 기다릴 필요 없이 즉시 실패합니다. Obsidian이 떠 있지 않을 때 CI 로그가 훨씬 깔끔해집니다.

이 두 개선은 공유 모듈 추출이 아니었다면 이뤄지지 않았을 변경입니다. 파일을 하나로 만들면서 "이 파일을 이제 두 스위트가 모두 의존한다"는 인식이 생기면 자연스럽게 품질을 한 단계 올리고 싶어집니다. Copy-paste는 이런 식의 품질 향상을 유도하지 않습니다.

4.3 두 스위트의 import

두 스위트 모두 상단 한 줄로 공유 모듈을 가져옵니다.

// tests/e2e/run-e2e.mjs
/**
 * E2E Test Suite for Auto Note Importer
 *
 * @covers src/core/sync-orchestrator.ts
 * @covers src/core/config-manager.ts
 * @covers src/core/config-instance.ts
 * @covers src/core/conflict-resolver.ts
 * @covers src/services/airtable-client.ts
 * @covers src/builders/bases-file-generator.ts
 * @covers src/main.ts
 */

import { findPageTarget, evalInObsidian } from './cdp-helpers.mjs';
// tests/e2e/run-settings-e2e.mjs
/**
 * Settings UI E2E Tests for Auto Note Importer
 *
 * Tests summary card rendering, badge statuses, summaries, expand/collapse,
 * and all config option combinations via Chrome DevTools Protocol (CDP).
 *
 * @covers src/ui/settings-tab.ts
 */

import { findPageTarget, evalInObsidian } from './cdp-helpers.mjs';

각 스위트가 자기만 커버하는 소스 파일@covers 마커로 명시합니다. run-e2e.mjs는 코어 싱크 파이프라인 7개를 커버하고, run-settings-e2e.mjs는 UI 레이어(settings-tab.ts) 하나를 커버합니다. 이 분리가 "두 스위트가 정확히 무엇을 검증하는가"를 명시적으로 만들어 줍니다.

5. Settings UI 스위트의 헬퍼는 완전히 다르다

공유는 CDP 인프라에만 적용했습니다. Obsidian-side 헬퍼는 두 스위트가 전혀 다른 것을 필요로 합니다.

5.1 싱크 스위트의 헬퍼 (run-e2e.mjs)

파일 CRUD, 필드 수정, 캐시 동기화 같은 데이터 레이어 조작이 주 목적입니다.

// run-e2e.mjs 내부
const HELPERS = `
  function getPlugin() { return app.plugins.plugins['auto-note-importer']; }

  function waitForCache(file, key, val, maxWait = 3000) {
    // metadataCache 동기화 대기
  }

  async function openAndActivate(path) {
    const file = app.vault.getAbstractFileByPath(path);
    const leaf = app.workspace.getLeaf(false);
    await leaf.openFile(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);
  }
`;

5.2 Settings UI 스위트의 헬퍼 (run-settings-e2e.mjs)

Settings 탭 열기, card/badge 쿼리, collapse/expand 조작 같은 UI 레이어 조작이 주 목적입니다.

// run-settings-e2e.mjs 내부
const HELPERS = `
  function getPlugin() { return app.plugins.plugins['auto-note-importer']; }

  function getActiveConfig() {
    const p = getPlugin();
    const id = p.settings.activeConfigId;
    return p.settings.configs.find(c => c.id === id) || p.settings.configs[0];
  }

  function getSettingsTab() {
    return app.setting.pluginTabs.find(t => t.id === 'auto-note-importer');
  }

  async function openSettingsTab() {
    app.setting.open();
    await new Promise(r => setTimeout(r, 200));
    const tab = getSettingsTab();
    if (!tab) throw new Error('Plugin settings tab not found');
    app.setting.openTab(tab);
    tab.display();
    await new Promise(r => setTimeout(r, 400));
    return tab;
  }

  async function rerenderTab() {
    const tab = getSettingsTab();
    if (tab) tab.display();
    await new Promise(r => setTimeout(r, 300));
    return tab;
  }

  function getContainer() {
    const tab = getSettingsTab();
    return tab?.containerEl;
  }

  function queryCards(container) {
    const el = container || getContainer();
    return Array.from(el.querySelectorAll('.ani-summary-card'));
  }

  function cardInfo(card) {
    return {
      title: card.querySelector('.ani-card-title')?.textContent || '',
      summary: card.querySelector('.ani-card-summary')?.textContent || '',
      badge: card.querySelector('.ani-card-badge')?.textContent || '',
      isOk: !!card.querySelector('.ani-card-badge-ok'),
      isOff: !!card.querySelector('.ani-card-badge-off'),
      expanded: card.classList.contains('is-expanded'),
    };
  }
`;

이 두 헬퍼 세트는 겹치는 함수가 거의 없습니다. getPlugin()만 공통이고 나머지는 전부 목적이 다릅니다. 그래서 각 스위트 안에 두는 것이 맞습니다.

5.3 그래도 남은 약간의 중복

getPlugin() 같은 사소한 공통 헬퍼는 두 파일에 각각 있습니다. 이것까지 공유하려면:

  1. obsidian-helpers.mjs 같은 두 번째 공유 모듈을 만들거나
  2. HELPERS 문자열을 부분 concat하거나

둘 다 지금 시점에는 과하다고 판단했습니다. **"공통 함수가 3개 미만이면 중복 감수"**를 기준으로 삼았습니다. 이 규칙은 경험칙이지만, 추출이 가치를 만들기 시작하는 임계점이 대체로 여기쯤입니다. 2개 이하면 dup이 더 명료하고, 4개 이상이면 추출이 명백히 이득입니다.

6. 하나의 Obsidian 인스턴스, 두 개의 스위트

두 E2E 스위트는 같은 Obsidian 인스턴스에 대해 돌아갑니다. 새 인스턴스를 띄우는 게 아닙니다.

6.1 실행 흐름

# 1. Obsidian을 CDP 모드로 실행 (한 번만)
/Applications/Obsidian.app/Contents/MacOS/Obsidian --remote-debugging-port=9222 &

# 2. 싱크 스위트 실행 (11 tests, ~3분)
npm run test:e2e

# 3. 설정 스위트 실행 (42 tests, ~1분)
npm run test:e2e:settings

두 스위트 사이에 Obsidian을 재시작하지 않습니다. 같은 vault, 같은 플러그인 인스턴스를 공유합니다.

6.2 스위트 간 격리

이건 서로의 사이드이펙트가 없어야 한다는 뜻입니다. 싱크 스위트가 테스트 레코드를 남겨두면 설정 스위트에서 예상 밖의 상태를 보게 됩니다.

격리를 위한 규칙 3개:

  1. 싱크 스위트는 자기가 만든 레코드를 자기가 정리. --cleanup 플래그로 강제.
  2. 설정 스위트는 설정을 변경하지 않거나, 변경 후 원래 값으로 복원. describe → setup → test → teardown 순서.
  3. 두 스위트는 같은 vault의 다른 폴더를 사용. 싱크 스위트는 Sync/, 설정 스위트는 설정 값만 읽고 파일을 생성하지 않음.

6.3 병렬 vs 순차

두 스위트를 병렬로 돌릴 수 있을까요? 이론적으로는 두 개의 Obsidian 인스턴스를 다른 --remote-debugging-port(9222, 9223)로 띄우면 됩니다. cdp-helpers.mjsCDP_PORT 환경 변수를 지원하기 때문에 구조적으로는 가능합니다.

# 이론적인 병렬 실행
Obsidian --remote-debugging-port=9222 --user-data-dir=/tmp/obs-sync &
Obsidian --remote-debugging-port=9223 --user-data-dir=/tmp/obs-settings &

CDP_PORT=9222 npm run test:e2e &
CDP_PORT=9223 npm run test:e2e:settings &
wait

하지만 실제로는 순차 실행을 선택했습니다. 이유:

  • macOS에서는 같은 .app을 두 번 실행하는 게 번거로움 (--user-data-dir 같은 플래그는 먹히지 않음).
  • 병렬 실행이 테스트 시간을 유의미하게 줄이지 못함 — 각 스위트 자체가 충분히 빠름(합쳐서 4분).
  • 디버깅 로그가 섞이면 실패 원인 찾기가 어려움.

병렬의 이점이 설정의 복잡도를 이기지 못했습니다. "이론적으로 가능한 것"과 "실제로 채택할 가치가 있는 것"은 다릅니다.

7. 회고 — 하나로 시작해서 둘이 된 구조의 교훈

7.1 첫 번째 스위트를 만들 때 공유 모듈을 미리 만들지 않았다

이건 옳은 결정이었습니다. 첫 번째 스위트밖에 없을 때 cdp-helpers.mjs를 만들면, 그건 "미래의 가상 사용자"를 위한 추상화입니다. 그때는 실제 사용자가 한 명이라 helper 인터페이스가 어떻게 생겨야 하는지 모릅니다.

두 번째 사용자가 나타나면 그제서야 "실제로 공통인 것"과 "실제로 다른 것"의 경계가 보입니다. **"2번째가 나타났을 때 리팩토링"**이 rule of three의 중간 단계 버전입니다. 너무 이른 추상화는 잘못된 인터페이스를 고정화시킵니다.

7.2 공유는 가장 저수준 레이어에만

CDP 인프라는 공유, Obsidian-side 헬퍼와 테스트 케이스는 공유하지 않음. 이 **"공유의 경계"**가 핵심입니다. 공유 모듈이 너무 많은 걸 떠안으면:

  • 스위트 하나가 자기 헬퍼를 바꾸려고 하면 다른 스위트가 영향받음
  • 공유 모듈이 스위트별 특수 케이스를 수용하려고 분기가 쌓임
  • 결국 공유 모듈이 "God object"가 됨

Sharing as a lowest common denominator, not as a feature aggregator. 공유는 최소 공약수를 향해야지 기능의 합집합을 향하면 안 됩니다.

7.3 추출이 발견한 작은 품질 개선

섹션 4.2에서 언급한 CDP_PORT 환경 변수와 error 핸들러는, 추출 과정이 아니었다면 나오지 않았을 개선입니다. **"이 파일이 이제 두 스위트가 의존하는 경로"**라는 인식이 자연스럽게 품질을 한 단계 올립니다. Copy-paste에서는 이런 강제력이 작동하지 않습니다.

7.4 @covers 마커가 이 분리를 명시해 준다

양방향 마커 시스템을 도입한 뒤로, 각 E2E 스위트가 정확히 어떤 소스 파일을 커버하는지가 파일 상단에 명시돼 있습니다.

  • run-e2e.mjssync-orchestrator, config-manager, config-instance, conflict-resolver, airtable-client, bases-file-generator, main
  • run-settings-e2e.mjssettings-tab

이 분리가 두 스위트의 존재 이유를 코드 안에 문서화합니다. 누가 나중에 "왜 E2E가 두 개나 있지?"라고 물으면 @covers 리스트를 보여주면 됩니다.

8. FAQ

Q: 왜 첫 글의 published 버전을 수정하지 않고 속편을 썼나요?

A: 첫 글은 "CDP로 E2E를 시작하는 방법" 자체가 주제였습니다. 새 스위트 추가 이야기를 거기에 끼워 넣으면 원래의 narrative가 흐려집니다. 분리해서 다루는 게 두 글 모두에게 낫다고 판단했습니다. 속편이 필요할 때는 속편을 쓰면 됩니다.

Q: 세 번째 스위트가 생기면 어떻게 하나요?

A: 공유 모듈은 이미 추출돼 있으므로 import 한 줄로 시작할 수 있습니다. 세 번째 스위트가 기존 두 스위트 중 하나와 비슷하면 그쪽 헬퍼 패턴을 참고하고, 완전히 다른 성격(예: 플러그인 설치/언로드 생명주기 테스트)이면 독립된 헬퍼 세트를 만들면 됩니다. 경계 원칙은 동일: 저수준만 공유, 도메인 헬퍼는 각자.

Q: Vitest나 Playwright로 가는 게 맞는 시점은 언제인가요?

A: 두 가지 시점 중 하나가 오면 고려합니다. 첫째, 테스트 케이스 수가 100개를 넘으면서 reporter·retry·parallel 같은 프레임워크 기능이 필요해질 때. 둘째, CI에서 headless 실행이 필수가 될 때 — 지금은 로컬 전용이라 GUI Obsidian을 띄워 쓰지만, CI에서 돌리려면 프레임워크가 제공하는 container orchestration이 훨씬 편합니다. 현 시점은 둘 다 아닙니다.

Q: 두 스위트가 상태를 공유해서 flaky해진 적은 없나요?

A: 한 번 있었습니다. 싱크 스위트가 --cleanup을 빼먹고 돌았을 때, 설정 스위트가 "config가 하나 더 있는" 상태를 만나서 config count 검증에 실패했습니다. 해결은 **설정 스위트가 실행 전에 "config 개수가 기대값인지 먼저 확인"**하는 defensive check를 추가하는 것이었습니다. 지금은 두 스위트 모두 "예상 상태가 아니면 빠르게 실패"하는 pre-check를 가지고 있습니다.

Q: 이 패턴을 VS Code 익스텐션에도 적용할 수 있나요?

A: 네. VS Code의 Extension Development Host도 CDP 포트를 노출할 수 있습니다(--inspect 플래그). 같은 패턴을 그대로 적용해서 "Extension 명령 자동화" 스위트와 "Settings/Webview 자동화" 스위트를 분리할 수 있습니다. 다만 VS Code는 Playwright에 공식 통합이 있어서(@vscode/test-electron), 프로젝트가 커지면 그쪽으로 옮기는 게 더 경제적일 수 있습니다.

9. 참고 자료


🧩 Obsidian 플러그인 개발 시리즈 (7부작)

  1. Obsidian CLI 없이 플러그인 E2E 테스트하기
  2. 여러 Airtable 동시 싱크: Multi-Config 아키텍처
  3. Multi-Database 리팩토링: DatabaseProvider 추상화
  4. Per-Instance 전환 후 찾아온 메모리 누수 잡기
  5. 두 번째 E2E 스위트와 공유 CDP 헬퍼 (현재 글)
  6. 릴리스에서 styles.css가 사라지는 이유
  7. window.confirm() 대신 두 번 클릭 패턴