Playwright MCP 로 크롬 익스텐션 popup UI 평가하기

정기창·

이번에 제가 만든 작은 크롬 익스텐션의 popup UI 를 AI 평가 하네스에 맡겨보려다가 작은 벽에 부딪혔습니다. Playwright MCP 가 평범한 웹페이지는 잘 다루지만, chrome-extension:// 컨텍스트로 직접 붙어주지는 않는다는 사실이었습니다.

처음에는 빌드해서 unpacked 익스텐션을 로드하는 방식을 떠올렸지만, MCP 인터페이스를 통해 그 흐름을 짜는 게 생각보다 번거롭다는 결론에 다다랐습니다. 그러다 문득, popup 도 결국 HTML + JS + CSS 일 뿐이라는 사실이 떠올랐습니다. 그 안에서 chrome.* API 호출과 인증이 필요한 fetch 만 어떻게든 떼어낼 수 있다면, 평범한 정적 페이지처럼 띄워서 평가할 수 있겠다는 가설이 생겼습니다.

왜 Playwright MCP 는 익스텐션을 직접 다루지 못하는가

익스텐션 popup 을 진짜 그대로 띄우려면 두 가지 조건이 필요합니다. 첫째, 빌드된 unpacked 디렉터리를 --load-extension 플래그로 미리 로드해야 합니다. 둘째, 그렇게 로드된 익스텐션의 내부 URL — chrome-extension://<id>/popup.html — 을 알아내서 거기로 이동해야 합니다.

Playwright 라이브러리 자체로는 가능한 일입니다. 다만 MCP 라는 추상화 계층은 일반적인 URL 탐색·클릭·스크린샷 위주로 다듬어져 있다 보니, 익스텐션 로드 같은 부가 옵션을 표현하기에는 결이 잘 맞지 않는다는 생각이 들었습니다. 평가만을 위해 MCP 외부의 Playwright 환경을 따로 세팅하는 것도 가능하겠지만, 그 정도 인프라를 들이기에는 평가 한 사이클이 너무 짧다는 판단이었습니다.

분장(harness) 으로 풀어낸 네 가지 조각

결국 익스텐션 popup 을 "분장" 시키기로 했습니다. popup 의 본체 코드는 그대로 두고, 그 주위의 런타임 환경만 가짜로 깔아주는 방식입니다. 평가 산출물은 .ui-eval/ 디렉터리에 모았고, 평가용 자산만 그 안에 격리했습니다.

1. popup.html 변종 사본 — 시나리오 주입

원본 popup.html 을 4 변종으로 복사했습니다. 영어/한국어 × Used/Remaining 표시 모드의 조합입니다. 변종마다 한 줄짜리 시나리오 객체를 전역에 박아두는 것이 핵심이었습니다.

<!-- .ui-eval/popup-en-used.html 의 마지막 부분 -->
<script>window.__MOCK__={ lang: "en", displayMode: "used" };</script>
<script src="mock.js"></script>
<script src="../popup.js"></script>

중요한 결정은 원본 popup.js 를 한 줄도 건드리지 않았다는 점입니다. 평가 대상 코드는 익스텐션이 실제로 배포하는 그 파일 그대로이며, 분장은 그 바깥에만 존재합니다. 그래야 평가 결과를 "현재 출시본" 의 결과라고 부를 수 있겠다는 생각이 들었습니다.

2. chrome.* API 의 메모리 mock

popup.js 안에서 호출되는 chrome.* 네임스페이스는 다섯 군데였습니다. chrome.runtime, chrome.storage.local, chrome.i18n, chrome.action, chrome.alarms 입니다. 이 다섯을 통째로 가짜로 깔았습니다.

// .ui-eval/mock.js (요약)
const localStore = {
  uiLanguage: scenario.uiLanguage,
  badgeMode: scenario.badgeMode || 'session',
  displayMode: scenario.displayMode || 'used',
};

window.chrome = {
  runtime: {
    getURL: (path) => {
      if (path === '_locales/en/messages.json') return './en.json';
      if (path === '_locales/ko/messages.json') return './ko.json';
      return path;
    },
    sendMessage: () => {},
    onMessage: { addListener: () => {} },
  },
  storage: {
    local: {
      get(keys, cb) { /* localStore 에서 동기/Promise 양쪽 모두 응답 */ },
      set(items, cb) { Object.assign(localStore, items); /* ... */ },
    },
    onChanged: { addListener: () => {} },
  },
  i18n: {
    getUILanguage: () => (lang === 'ko' ? 'ko-KR' : 'en-US'),
    getMessage: (key) => key,
  },
  action: { setBadgeText: () => {}, setBadgeBackgroundColor: () => {} },
  alarms: { create: () => {}, onAlarm: { addListener: () => {} } },
};

가장 신경 쓴 부분은 chrome.storage.local.get 이었습니다. popup.js 가 콜백 스타일과 Promise 스타일 양쪽으로 storage 를 호출했기 때문에, mock 도 두 형태 모두에 동일한 결과를 돌려줘야 했습니다. 콜백 인자가 있으면 호출 후 undefined 반환, 없으면 Promise 반환하는 패턴으로 정리했습니다.

3. window.fetch 가로채기 — 인증 우회

popup.js 는 Anthropic Console API 두 엔드포인트를 호출합니다. 조직 목록과 사용량 조회입니다. 익스텐션 환경에서는 사용자가 로그인된 claude.ai 의 쿠키가 자동으로 따라붙지만, 정적 HTML 에서는 그 쿠키를 흉내 낼 수 없다는 점이 다음 고민이었습니다.

const realFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
  const url = typeof input === 'string' ? input : input.url;
  if (url.endsWith('/api/organizations')) {
    return new Response(JSON.stringify(orgPayload), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  }
  if (url.includes('/api/organizations/') && url.endsWith('/usage')) {
    return new Response(JSON.stringify(usage), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  }
  // 로케일 JSON 같은 상대 경로는 진짜 fetch 로 통과
  return realFetch(input, init);
};

이 단계에서 한 가지 작은 발견이 있었습니다. Console API 만 가로채고 나머지는 그대로 통과시키니, 로케일 JSON 같은 정적 파일은 별다른 처리 없이 자연스럽게 로드되었습니다. 결국 가짜로 흉내 낼 부분과 진짜로 둘 부분의 경계를 명확히 그은 셈입니다.

4. 데이터 freezing — 평가의 결정성 확보

마지막 조각은 데이터를 결정적(deterministic) 으로 고정하는 일이었습니다. 평가 리포트가 "주간 3%, 5시간 12%, Sonnet 8%" 같은 구체 수치를 근거로 이슈를 짚는 만큼, 다음번에 다시 돌렸을 때 같은 화면이 나와야 비교가 가능하다는 생각이 들었습니다.

const usage = scenario.usage || {
  seven_day: {
    utilization: 3,
    resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(),
  },
  five_hour: {
    utilization: 12,
    resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000 + 15 * 60 * 1000).toISOString(),
  },
  seven_day_sonnet: {
    utilization: 8,
    resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
  },
};

_locales/{en,ko}/messages.json 도 .ui-eval/en.json, ko.json 으로 사본을 떠두었습니다. 본 익스텐션 코드 안의 메시지가 갱신되어도 평가 결과는 직전 시점의 카피본을 기준으로 평가되므로, 이상하게 들릴 수 있지만 평가 시점의 동결된 컨텍스트를 의도적으로 유지한 셈입니다.

Playwright MCP 가 실제로 한 일

여기까지 분장하고 나면 Playwright MCP 의 역할은 단순해집니다. file://.ui-eval/popup-en-used.html 같은 로컬 정적 HTML 4 개를 차례로 열어 스크린샷을 찍는 것뿐입니다. 익스텐션 호스트 권한, MV3 service worker, chrome.runtime 메시징, 어느 것도 거치지 않습니다.

.ui-eval/
├─ popup-en-used.html       ← 시나리오 주입된 변종
├─ popup-en-remaining.html
├─ popup-ko-used.html
├─ popup-ko-remaining.html
├─ mock.js                  ← chrome.* + fetch 분장
├─ en.json / ko.json        ← 로케일 freezing
├─ popup-*.png              ← MCP 가 찍은 4 장
└─ report.md / report.html  ← AI 평가자 산출물

그렇게 모인 4 장의 PNG 와 동결된 시나리오 데이터는 그대로 AI UI/UX 평가 스킬의 입력이 되었습니다. AI 평가자는 일반 웹페이지를 평가할 때와 동일한 흐름으로 점수표, 이슈 카드, Nielsen 휴리스틱 평가를 만들어주었습니다.

얻은 것과 놓치는 것

이 접근은 만능이 아닙니다. 명백히 평가 범위 바깥으로 밀어낸 영역이 있다는 점을 솔직히 적어두는 편이 좋겠습니다.

얻은 것 놓치는 것
익스텐션 빌드·설치 없이 4 변종 시각 비교 익스텐션 lifecycle (popup open/close 시 상태 동기)
결정적 데이터로 이슈 재현 가능 실제 badge 페인트, chrome.alarms 트리거
원본 popup.js / popup.html 미수정 chrome.storage.onChanged 리스너 동작
AI 평가 하네스의 일반 흐름 그대로 적용 백그라운드 service worker 의 fetch interval

다시 말해 이 분장은 "popup 의 정적 렌더 + 시나리오 분기 + i18n" 까지만 평가 대상으로 좁혔습니다. popup 이 다시 열렸을 때 상태를 어떻게 보존하는지 같은 동적 시나리오는 의도적으로 평가 범위에서 빼낸 결정입니다.

돌이켜본 교훈

처음에는 "익스텐션 직접 평가가 안 된다" 는 사실이 작은 좌절이었습니다. 다만 곰곰이 생각해보니, 평가 도구의 한계를 우회하는 일과 평가 범위를 의도적으로 좁히는 일은 결국 같은 결정이라는 사실에 다다랐습니다.

분장은 "이 도구가 못하는 걸 억지로 시키는" 우회가 아니라, "이 도구가 잘하는 영역만 남기고 나머지는 잘라낸" 범위 결정에 가까웠다는 생각이 들었습니다. AI 평가 하네스가 잘 다루는 시각 휴리스틱·라벨 일관성·i18n 부정합 같은 정적 품질은 충분히 살아남았고, 실제로 그 평가가 곧장 다음 핫픽스 PR 의 우선순위 큐가 되어주었습니다.

그럼에도 불구하고 여전히 빈 공간은 남습니다. 동적 시나리오, lifecycle, 백그라운드 동작 같은 영역은 별도의 검증 경로가 필요하다는 사실을 잊지 않으려 합니다. 결국 도구 하나로 모든 걸 덮으려 하지 말고, 도구마다 잘하는 자리에 두는 것이 평가 인프라를 오래 유지하는 길이라는 결론입니다.

Chrome ExtensionPlaywright MCPUI/UXClaude CodeTesting